Compare commits

..

99 Commits

Author SHA1 Message Date
a96e89a86e Release 1.155.0 (#977) 2022-05-29 15:39:57 +02:00
b9c9443899 Bugfix/fix empty state of proportion chart (#976)
* Fix empty state (chart with two levels)

* Update changelog
2022-05-29 15:38:13 +02:00
f1e06347d3 Feature/add data source eod historical data (#974)
* Add EOD Historical Data as a data source

* Update changelog
2022-05-29 15:37:40 +02:00
697e92f818 Feature/finalize exposing redis password env variable (#975)
* Add hints

* Update changelog
2022-05-29 14:54:53 +02:00
b678998801 Feature/add-redis-password (#947)
* Expose REDIS_PASSWORD
2022-05-29 14:18:57 +02:00
de53cf1884 Release 1.154.0 (#973) 2022-05-28 21:12:42 +02:00
bbe30218bd Feature/remove dependency round to (#972)
* Remove round-to dependency

* Update changelog
2022-05-28 21:10:45 +02:00
15dda886a0 Feature/add vertical hover line to line chart component (#963)
* Add vertical hover line

* Improve tooltips of charts

* Update changelog
2022-05-28 20:53:54 +02:00
34d4212f55 Feature/modernize pricing page (#967)
* Simplify pricing page

* Update changelog
2022-05-28 18:52:30 +02:00
f7060230b7 Update dates (#966) 2022-05-28 18:46:22 +02:00
0fdafcb7e4 Release 1.153.0 (#962) 2022-05-27 11:53:18 +02:00
e79be9f2d6 Feature/do not tweet on weekend (#961)
* Do not tweet on the weekend

* Update changelog
2022-05-27 11:37:48 +02:00
69088b93a6 Feature/add value redaction as interceptor (#960)
* Add value redaction as interceptor

* Update changelog
2022-05-27 11:21:47 +02:00
c3768a882d Feature/add benchmarks to twitter bot service (#959)
* Extend benchmarks with market condition and adapt twitter bot service

* Update changelog
2022-05-27 10:03:37 +02:00
3498ed8549 Feature/upgrade prisma to version 3.14.0 (#958)
* Upgrade prisma dependencies to version 3.14.0

* Update changelog
2022-05-27 09:50:38 +02:00
c07c300fef Move @simplewebauthn/typescript-types to devDependencies (#957) 2022-05-27 09:49:57 +02:00
c62a5af9eb Bugfix/fix width of skeleton loader in benchmark component (#956)
* Fix width

* Update changelog
2022-05-27 09:49:37 +02:00
0c04f10e19 Release 1.152.0 (#955) 2022-05-26 19:01:08 +02:00
2c4c16ec99 Feature/extend markets overview by benchmarks (#953)
* Add benchmarks to markets overview

* Update changelog
2022-05-26 18:59:29 +02:00
4711b0d1ed Improve instructions for Unraid (#954) 2022-05-26 17:59:09 +02:00
a8521e0ecf Update README.md (#943)
* Update README.md for Unraid users
2022-05-26 17:52:14 +02:00
424748ae90 Feature/add ghostfolio trailer to landing page (#952)
* Add link to Ghostfolio trailer

* Update changelog
2022-05-26 10:30:13 +02:00
9c4d8bdf4b Release 1.151.0 (#949) 2022-05-24 20:58:24 +02:00
332203b9e2 Feature/add support to set the base currency via env variable (#948)
* Set base currency via environment variable

* Update changelog
2022-05-24 20:55:55 +02:00
f48832c671 Bugfix/add missing conversion of countries (#941)
* Add missing conversion of countries for SymbolProfileOverrides

* Update changelog
2022-05-23 18:04:09 +02:00
ae8a203526 Add type (#939) 2022-05-22 21:14:22 +02:00
d0c1506ded Release 1.150.0 (#940) 2022-05-21 20:00:34 +02:00
af0863d193 Bugfix/fix currency conversion in accounts (#937)
* Fix currency conversion in accounts

* Update changelog
2022-05-21 19:58:47 +02:00
f5819cc399 Bugfix/fix countries in symbol profile overrides (#936)
* Fix countries

* Update changelog
2022-05-20 20:16:23 +02:00
977c5a9544 Feature/skip data enhancement if data is inaccurate (#935)
* Skip data enhancer if data is inaccurate

* Update changelog
2022-05-20 20:15:19 +02:00
b9cd42cd53 Move dependencies to devDependencies (#934) 2022-05-20 20:14:33 +02:00
379977008d Simplify intro text (#933) 2022-05-19 21:05:14 +02:00
38f9d54705 Release 1.149.0 (#927) 2022-05-16 21:50:43 +02:00
5cb6e5dec6 Feature/support filtering by asset class on the allocations page (#926)
* Support filtering by asset class

* Update changelog
2022-05-16 21:49:22 +02:00
4a123c38f2 Refactor placeholder (#925) 2022-05-16 21:17:58 +02:00
160335302a Feature/group filters by type (#922)
* Add groups to activities filter component

* Update changelog
2022-05-15 21:51:31 +02:00
f1483569a2 Release 1.148.0 (#921) 2022-05-14 13:55:25 +02:00
5391b88c42 Feature/add report data glitch button (#920)
* Add report data glitch button

* Update changelog
2022-05-14 13:53:43 +02:00
2b63f7e707 Feature/support enter to submit create or update transaction dialog form (#913)
* Support enter key press to submit form

* Update changelog
2022-05-14 10:56:07 +02:00
d5c96d1cb7 Bugfix/fix date picker date format (#912)
* Fix date picker date format

* Update changelog
2022-05-14 10:55:09 +02:00
1a4dc51825 Bugfix/fix state of delete account button (#911)
* Fix disable state

* Update changelog
2022-05-13 06:48:40 +02:00
d094bae7de Bugfix/fix issue in activities filter component with typing (#910)
* Handle filter (selecting) or search term (typing)

* Update changelog
2022-05-12 07:48:12 +02:00
57bf10e7e7 Release 1.147.0 (#904) 2022-05-10 21:25:25 +02:00
c1d460cead Improve filtering (#901) 2022-05-10 21:24:36 +02:00
dfa67b275c Feature/improve filtering on allocations page (#900)
* Include cash positions on allocations page (with no filtering)

* Update changelog
2022-05-10 19:22:57 +02:00
80862e5c2a Release 1.146.3 (#899) 2022-05-08 22:54:07 +02:00
904d4db219 Refactor build:all and build:dev scripts (#898) 2022-05-08 22:52:46 +02:00
10f13eec48 Release 1.146.2 (#897) 2022-05-08 22:18:00 +02:00
ea3a9d3b79 Feature/eliminate circular dependencies in common library (#896)
* Eliminate circular dependencies

* Update changelog
2022-05-08 22:16:47 +02:00
e55b05fe3d Release 1.146.1 (#895) 2022-05-08 16:57:23 +02:00
32dd76be5f Fix path to jest files (#894) 2022-05-08 16:55:14 +02:00
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
21173bed21 Release 1.145.0 (#884) 2022-05-07 11:47:28 +02:00
16dd8f7652 Feature/refactor filters with interface (#883)
* Refactor filtering with an interface

* Filter by accounts

* Update changelog
2022-05-07 11:44:29 +02:00
ce6b5fb7cb Bugfix/fix tooltip in proportion chart after update (#882)
* Keep tooltip configuration up to date

* Update changelog
2022-05-01 08:38:57 +02:00
f6f62db830 Feature/add support for private equity (#881)
* Add support for private equity

* Update changelog
2022-04-30 22:16:13 +02:00
01103f3db4 Feature/add asset and asset sub class to wealth items form (#880)
* Add asset and asset sub class

* Update changelog
2022-04-30 21:47:10 +02:00
e9e9f1a124 Release 1.144.0 (#879) 2022-04-30 11:51:49 +02:00
751256f158 Feature/add support for real estate and precious metal (#878)
* Add support for real estate and precious metal

* Update changelog
2022-04-30 11:49:58 +02:00
c2a1cbd20f Feature/improve layout of position detail dialog (#877)
* Improve layout

* Update changelog
2022-04-30 10:48:02 +02:00
04044f8720 Support futures (#845)
* Support futures

* Upgrade yahoo-finance2 to version 2.3.2

* Update changelog
2022-04-30 09:55:24 +02:00
4dc76817ce Bugfix/fix import validation for numbers equal zero (#875)
* Fix import validation for numbers equal 0

* Update changelog
2022-04-29 13:10:45 +02:00
1f0bd5a7db Bugfix/fix color of spinner in filter component (#873)
* Fix color for dark mode

* Update changelog
2022-04-27 17:30:57 +02:00
b6cd007ad4 Release/1.143.0 (#871)
* Release 1.143.0
  * Improve filtering by tags
2022-04-26 22:31:53 +02:00
b4bc72c6f9 Release 1.142.0 (#869) 2022-04-25 22:41:02 +02:00
899fa0370e Feature/improve users table of admin control panel (#866)
* Improve users table

* Update changelog
2022-04-25 22:39:08 +02:00
da27504aa1 Add orderBy statement to make debugging easier (#868) 2022-04-25 22:37:56 +02:00
b7bbc029ac Feature/render tags in dialogs (#864)
* Render tags

* Update changelog
2022-04-25 22:37:34 +02:00
c61a415fb2 Bugfix/change date to utc in data gathering service (#867)
* Change date to UTC

* Update changelog
2022-04-25 18:12:42 +02:00
8ff811ed28 Release/1.141.1 (#863) 2022-04-24 17:27:00 +02:00
9a2ea0a4ed Release 1.141.0 (#862) 2022-04-24 16:24:21 +02:00
bad9d17c44 Setup allocations page and endpoint (#859)
* Setup tagging system

* Update changelog
2022-04-24 16:23:03 +02:00
ea89ca5734 Feature/simplify ids in database schema (#861)
* Simplify ids in database schema
  * Access
  * Order
  * Subscription

* Update changelog
2022-04-24 09:35:01 +02:00
8f61f7c169 Feature/extract activities table filter component (#858)
* Extract activities table component

* Update changelog
2022-04-23 19:22:20 +02:00
edca05f542 Feature/change get started url of public page (#857)
* Change url

* Update changelog
2022-04-23 16:48:23 +02:00
283f054ee2 Feature/upgrade prisma to version 3.12.0 (#856)
* Upgrade prisma to version 3.12.0

* Update changelog
2022-04-23 13:38:41 +02:00
e9a46cb224 Release 1.140.2 (#854) 2022-04-23 09:52:22 +02:00
4a75c6d483 Release 1.140.1 (#853) 2022-04-22 21:45:50 +02:00
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
9f597cbff1 Release 1.139.0 (#843) 2022-04-18 11:59:16 +02:00
90efc2ac51 Feature/beautify etf names in asset profile (#842)
* Beautify ETF names

* Update changelog
2022-04-18 11:57:57 +02:00
056b318d86 Bugfix/fix end date in ics files (#841)
* Fix end date

* Update changelog
2022-04-18 11:31:16 +02:00
82ede2fe32 Bugfix/fix fear and greed data source (#840)
* Fix data source of Fear & Greed Index

* Update changelog
2022-04-18 10:49:02 +02:00
8ae041faa0 Bugfix/fix issue in fire calculator after changing investment horizon (#839)
* Properly update chart datasets and improve tooltip

* Update changelog
2022-04-18 10:31:16 +02:00
179 changed files with 5217 additions and 2595 deletions

7
.env
View File

@ -3,14 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES # POSTGRES
POSTGRES_DB=ghostfolio-db POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=GHOSTFOLIO ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456 JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
PORT=3333 PORT=3333

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,253 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.155.0 - 29.05.2022
### Added
- Added `EOD_HISTORICAL_DATA` as a new data source type
### Changed
- Exposed the environment variable `REDIS_PASSWORD`
### Fixed
- Fixed the empty state of the portfolio proportion chart component (with 2 levels)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.154.0 - 28.05.2022
### Added
- Added a vertical hover line to inspect data points in the line chart component
### Changed
- Improved the tooltips of the chart components (content and style)
- Simplified the pricing page
- Improved the rounding numbers in the twitter bot service
- Removed the dependency `round-to`
## 1.153.0 - 27.05.2022
### Added
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
- Extended the twitter bot service by benchmarks
- Added value redaction for the impersonation mode in the API response as an interceptor
### Changed
- Changed the twitter bot service to rest on the weekend
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
### Fixed
- Fixed a styling issue in the benchmark component on mobile
## 1.152.0 - 26.05.2022
### Added
- Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high)
## 1.151.0 - 24.05.2022
### Added
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
## 1.150.0 - 21.05.2022
### Changed
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
### Fixed
- Fixed an issue with the currency conversion in the account calculations
- Fixed an issue with countries in the symbol profile overrides
## 1.149.0 - 16.05.2022
### Added
- Added groups to the activities filter component
- Added support for filtering by asset class on the allocations page
## 1.148.0 - 14.05.2022
### Added
- Supported enter key press to submit the form of the create or edit transaction dialog
- Added a _Report Data Glitch_ button to the position detail dialog
### Fixed
- Fixed the date format of the date picker and support manual changes
- Fixed the state of the account delete button (disable if account contains activities)
- Fixed an issue in the activities filter component (typing a search term)
## 1.147.0 - 10.05.2022
### Changed
- Improved the allocations page with no filtering (include cash positions)
## 1.146.3 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
### Fixed
- Eliminated the circular dependencies in the `@ghostfolio/common` library
## 1.145.0 - 07.05.2022
### Added
- Added support for filtering by accounts on the allocations page
- Added support for private equity
- Extended the form to set the asset and asset sub class for (wealth) items
### Changed
- Refactored the filtering (activities table and allocations page)
### Fixed
- Fixed the tooltip update in the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.144.0 - 30.04.2022
### Added
- Added support for commodities (via futures)
- Added support for real estate
### Changed
- Improved the layout of the position detail dialog
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
### Fixed
- Fixed the import validation for numbers equal 0
- Fixed the color of the spinner in the activities filter component (dark mode)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.143.0 - 26.04.2022
### Changed
- Improved the filtering by tags
## 1.142.0 - 25.04.2022
### Added
- Added the tags to the create or edit transaction dialog
- Added the tags to the position detail dialog
### Changed
- Changed the date to UTC in the data gathering service
- Reused the value component in the users table of the admin control panel
## 1.141.1 - 24.04.2022
### Added
- Added the database migration
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.141.0 - 24.04.2022
### Added
- Added a tagging system for activities
### Changed
- Extracted the activities table filter to a dedicated component
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.140.2 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022
### Added
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
### Changed
- Beautified the ETF names in the symbol profile
### Fixed
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
- Fixed the data source of the _Fear & Greed Index_ (market mood)
## 1.138.0 - 16.04.2022 ## 1.138.0 - 16.04.2022
### Added ### Added
@ -14,7 +261,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Separated the deposit and savings in the chart of the the _FIRE_ calculator - Separated the deposit and savings in the chart of the _FIRE_ calculator
## 1.137.0 - 15.04.2022 ## 1.137.0 - 15.04.2022
@ -292,7 +539,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3` - Upgraded `angular` from version `13.1.2` to `13.2.2`
- Upgraded `Nx` from version `13.4.1` to `13.8.1` - Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18` - Upgraded `storybook` from version `6.4.9` to `6.4.18`

View File

@ -22,8 +22,8 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json COPY ./angular.json angular.json
COPY ./nx.json nx.json COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.config.js jest.config.js COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps

View File

@ -9,7 +9,7 @@
<h1>Ghostfolio</h1> <h1>Ghostfolio</h1>
<p> <p>
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
@ -24,10 +24,11 @@
</p> </p>
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center"> <div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300"> <a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div> </div>
## Ghostfolio Premium ## Ghostfolio Premium
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism - 🧘 into minimalism
- 🧺 caring about diversifying your financial resources - 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence - 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2021 - 🙅 saying no to spreadsheets in 2022
- 😎 still reading this list - 😎 still reading this list
## Features ## Features
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode - ✅ Zen Mode
- ✅ Mobile-first design - ✅ Mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack ## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace. Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -128,6 +133,10 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` 1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
### Prerequisites ### Prerequisites
@ -246,6 +255,8 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License
© 2022 [Ghostfolio](https://ghostfol.io) © 2022 [Ghostfolio](https://ghostfol.io)

View File

@ -47,7 +47,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.js", "jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
@ -180,7 +180,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.js", "jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/common/jest.config.js", "jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
} }
@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"], "outputs": ["coverage/libs/ui"],
"options": { "options": {
"jestConfig": "libs/ui/jest.config.js", "jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
}, },

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'api', displayName: 'api',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json' tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000, testTimeout: 10000,
testEnvironment: 'node' testEnvironment: 'node',
preset: '../../jest.preset.ts'
}; };

View File

@ -78,8 +78,12 @@ export class AccessController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
const access = await this.accessService.access({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess) !hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -88,10 +92,7 @@ export class AccessController {
} }
return this.accessService.deleteAccess({ return this.accessService.deleteAccess({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
} }

View File

@ -1,8 +1,10 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -102,22 +104,43 @@ export class AccountService {
}); });
} }
public async getCashDetails( public async getCashDetails({
aUserId: string, currency,
aCurrency: string filters = [],
): Promise<CashDetails> { userId
}: {
currency: string;
filters?: Filter[];
userId: string;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0); let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({ const where: Prisma.AccountWhereInput = { userId };
where: { userId: aUserId }
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
}); });
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = await this.accounts({ where });
for (const account of accounts) { for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus( totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
aCurrency currency
) )
); );
} }

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -8,6 +12,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { import {
Body, Body,
Controller, Controller,
@ -23,6 +28,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,6 +39,8 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -71,10 +79,16 @@ export class AdminController {
); );
} }
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
this.dataGatheringService.gatherMax();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
this.dataGatheringService.gatherMax();
} }
@Post('gather/profile-data') @Post('gather/profile-data')
@ -92,9 +106,14 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@ -115,9 +134,10 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]); await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
return; symbol
});
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')

View File

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -15,11 +15,13 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client'; import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
@ -29,7 +31,9 @@ export class AdminService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
@ -43,15 +47,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== baseCurrency; return currency !== this.baseCurrency;
}) })
.map((currency) => { .map((currency) => {
return { return {
label1: baseCurrency, label1: this.baseCurrency,
label2: currency, label2: currency,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
baseCurrency, this.baseCurrency,
currency currency
) )
}; };

View File

@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -19,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
@ -36,6 +38,14 @@ import { UserModule } from './user/user.module';
AccountModule, AccountModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
password: process.env.REDIS_PASSWORD
}
}),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,

View File

@ -0,0 +1,32 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
};
}
}

View File

@ -0,0 +1,25 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

View File

@ -0,0 +1,84 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
}
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
return benchmarks;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule,
TagModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID, DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -33,7 +34,8 @@ export class InfoService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -52,9 +54,15 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource( if (
ghostfolioFearAndGreedIndexDataSource this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
); ) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
} }
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
@ -95,11 +103,13 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
systemMessage, systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
}; };
} }

View File

@ -1,4 +1,4 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
IsEnum, IsEnum,
IsISO8601, IsISO8601,
@ -10,14 +10,22 @@ import {
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
@IsOptional() @IsOptional()
dataSource: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;

View File

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
@ -42,8 +43,12 @@ export class OrderController {
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
if ( if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) !hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -52,15 +57,13 @@ export class OrderController {
} }
return this.orderService.deleteOrder({ return this.orderService.deleteOrder({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
@ -135,23 +138,15 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id_userId: { id
id,
userId: this.request.user.id
}
}); });
if (!originalOrder) { if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -178,15 +173,17 @@ export class OrderController {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id
id,
userId: this.request.user.id
}
} }
}); });
} }

View File

@ -4,11 +4,26 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataSource,
Order,
Prisma,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activity } from './interfaces/activities.interface';
@ -18,6 +33,8 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -55,6 +72,8 @@ export class OrderService {
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -77,6 +96,8 @@ export class OrderService {
}; };
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
const currency = data.SymbolProfile.connectOrCreate.create.currency; const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
@ -84,6 +105,8 @@ export class OrderService {
Account = undefined; Account = undefined;
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
@ -97,12 +120,10 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.gatherProfileData([ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
{ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol
symbol: data.SymbolProfile.connectOrCreate.create.symbol });
}
]);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -120,6 +141,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.accountId; delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
@ -151,11 +174,13 @@ export class OrderService {
} }
public async getOrders({ public async getOrders({
filters,
includeDrafts = false, includeDrafts = false,
types, types,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
@ -163,10 +188,64 @@ export class OrderService {
}): Promise<Activity[]> { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; where.isDraft = false;
} }
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
SymbolProfileOverrides: {
is: null
}
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {
@ -188,7 +267,8 @@ export class OrderService {
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true SymbolProfile: true,
tags: true
}, },
orderBy: { date: 'asc' } orderBy: { date: 'asc' }
}) })
@ -217,6 +297,8 @@ export class OrderService {
where where
}: { }: {
data: Prisma.OrderUpdateInput & { data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
@ -230,10 +312,10 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const name = data.SymbolProfile.connect.dataSource_symbol.symbol; delete data.SymbolProfile.connect;
data.SymbolProfile = { update: { name } };
} else { } else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
@ -250,6 +332,8 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.assetClass;
delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;

View File

@ -1,10 +1,24 @@
import { DataSource, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator'; import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
accountId: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
currency: string; currency: string;

View File

@ -1,6 +1,7 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
@ -33,8 +34,11 @@ function mockGetValue(symbol: string, date: Date) {
} }
export const CurrentRateServiceMock = { export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => { getValues: ({
const result = []; dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
if (dateQuery.lt) { if (dateQuery.lt) {
for ( for (
let date = resetHours(dateQuery.gte); let date = resetHours(dateQuery.gte);
@ -44,8 +48,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date, date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPriceInBaseCurrency: mockGetValue(
.marketPrice, dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }
@ -55,8 +61,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date, date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPriceInBaseCurrency: mockGetValue(
.marketPrice, dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }

View File

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data.service', () => {
return { return {
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null); exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
}, },
userCurrency: 'CHF' userCurrency: 'CHF'
}) })
).toMatchObject([ ).toMatchObject<GetValueObject[]>([
{ {
date: undefined, date: undefined,
marketPrice: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'
}, },
{ {
date: undefined, date: undefined,
marketPrice: 1847.839966, marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN' symbol: 'AMZN'
} }
]); ]);

View File

@ -28,13 +28,7 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise< const promises: Promise<GetValueObject[]>[] = [];
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
if (includeToday) { if (includeToday) {
const today = resetHours(new Date()); const today = resetHours(new Date());
@ -42,16 +36,17 @@ export class CurrentRateService {
this.dataProviderService this.dataProviderService
.getQuotes(dataGatheringItems) .getQuotes(dataGatheringItems)
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result = []; const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date: today, date: today,
marketPrice: this.exchangeRateDataService.toCurrency( marketPriceInBaseCurrency:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ?? this.exchangeRateDataService.toCurrency(
0, dataResultProvider?.[dataGatheringItem.symbol]
dataResultProvider?.[dataGatheringItem.symbol]?.currency, ?.marketPrice ?? 0,
userCurrency dataResultProvider?.[dataGatheringItem.symbol]?.currency,
), userCurrency
),
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }
@ -74,11 +69,12 @@ export class CurrentRateService {
return data.map((marketDataItem) => { return data.map((marketDataItem) => {
return { return {
date: marketDataItem.date, date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency( marketPriceInBaseCurrency:
marketDataItem.marketPrice, this.exchangeRateDataService.toCurrency(
currencies[marketDataItem.symbol], marketDataItem.marketPrice,
userCurrency currencies[marketDataItem.symbol],
), userCurrency
),
symbol: marketDataItem.symbol symbol: marketDataItem.symbol
}; };
}); });

View File

@ -1,5 +1,5 @@
export interface GetValueObject { export interface GetValueObject {
date: Date; date: Date;
marketPrice: number; marketPriceInBaseCurrency: number;
symbol: string; symbol: string;
} }

View File

@ -1,5 +1,9 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import {
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
@ -16,6 +20,7 @@ export interface PortfolioPositionDetail {
orders: OrderWithAccount[]; orders: OrderWithAccount[];
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number; transactionCount: number;
value: number; value: number;
} }
@ -25,10 +30,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean; isAllTimeLow: boolean;
items: HistoricalDataItem[]; items: HistoricalDataItem[];
} }
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

View File

@ -231,9 +231,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {}; marketSymbolMap[date] = {};
} }
if (marketSymbol.marketPrice) { if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big( marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice marketSymbol.marketPriceInBaseCurrency
); );
} }
} }
@ -548,9 +548,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {}; marketSymbolMap[date] = {};
} }
if (marketSymbol.marketPrice) { if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big( marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice marketSymbol.marketPriceInBaseCurrency
); );
} }
} }

View File

@ -4,13 +4,14 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
@ -19,7 +20,7 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -42,6 +43,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
private baseCurrency: string;
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -49,7 +52,9 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -102,18 +107,48 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
this.request.user.id, this.request.user.id,
range range,
filters
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -159,7 +194,11 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'; this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings }; return {
accounts,
hasError,
holdings: isBasicUser ? {} : holdings
};
} }
@Get('investments') @Get('investments')
@ -293,7 +332,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency this.request.user?.Settings?.currency ?? this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);

View File

@ -15,20 +15,21 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
ASSET_SUB_CLASS_EMERGENCY_FUND, ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY, UNKNOWN_KEY
baseCurrency
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReport,
@ -46,7 +47,12 @@ import type {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
DataSource,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
@ -62,11 +68,10 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy } from 'lodash'; import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
@ -77,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private baseCurrency: string;
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -88,7 +96,9 @@ export class PortfolioService {
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> { public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
@ -303,7 +313,8 @@ export class PortfolioService {
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string, aUserId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max',
aFilters?: Filter[]
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId); const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -312,13 +323,14 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency = const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ?? user.Settings?.currency ??
baseCurrency; this.request.user?.Settings?.currency ??
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
filters: aFilters
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -337,10 +349,11 @@ export class PortfolioService {
startDate startDate
); );
const cashDetails = await this.accountService.getCashDetails( const cashDetails = await this.accountService.getCashDetails({
userId, userId,
userCurrency currency: userCurrency,
); filters: aFilters
});
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus( const totalInvestment = currentPositions.totalInvestment.plus(
@ -362,7 +375,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems), this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]); ]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -433,24 +446,32 @@ export class PortfolioService {
}; };
} }
const cashPositions = await this.getCashPositions({ if (
cashDetails, aFilters?.length === 0 ||
emergencyFund, (aFilters?.length === 1 &&
userCurrency, aFilters[0].type === 'ASSET_CLASS' &&
investment: totalInvestment, aFilters[0].id === 'CASH')
value: totalValue ) {
}); const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
});
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol]; holdings[symbol] = cashPositions[symbol];
}
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts({
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId userId,
); filters: aFilters
});
return { accounts, holdings, hasErrors: currentPositions.hasErrors }; return { accounts, holdings, hasErrors: currentPositions.hasErrors };
} }
@ -472,8 +493,11 @@ export class PortfolioService {
); );
}); });
let tags: Tag[] = [];
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
tags,
averagePrice: undefined, averagePrice: undefined,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -494,12 +518,13 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].SymbolProfile.currency; const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [SymbolProfile] =
aSymbol await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
]);
const portfolioOrders: PortfolioOrder[] = orders const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return order.type === 'BUY' || order.type === 'SELL';
}) })
.map((order) => ({ .map((order) => ({
@ -514,6 +539,8 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
tags = uniqBy(tags, 'id');
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: positionCurrency, currency: positionCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -622,6 +649,7 @@ export class PortfolioService {
netPerformance, netPerformance,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: grossPerformancePercent:
@ -678,6 +706,7 @@ export class PortfolioService {
minPrice, minPrice,
orders, orders,
SymbolProfile, SymbolProfile,
tags,
averagePrice: 0, averagePrice: 0,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
@ -738,7 +767,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem), this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]); ]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -758,8 +787,7 @@ export class PortfolioService {
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: marketState:
dataProviderResponses[position.symbol]?.marketState ?? dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -873,12 +901,12 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts({
orders, orders,
portfolioItemsNow, portfolioItemsNow,
currency, userId,
userId userCurrency: currency
); });
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
@ -940,10 +968,10 @@ export class PortfolioService {
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balanceInBaseCurrency } = await this.accountService.getCashDetails( const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId, userId,
userCurrency currency: userCurrency
); });
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId
@ -1043,7 +1071,7 @@ export class PortfolioService {
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: convertedBalance, investment: convertedBalance,
marketPrice: 0, marketPrice: 0,
marketState: MarketState.open, marketState: 'open',
name: account.currency, name: account.currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
@ -1177,9 +1205,11 @@ export class PortfolioService {
} }
private async getTransactionPoints({ private async getTransactionPoints({
filters,
includeDrafts = false, includeDrafts = false,
userId userId
}: { }: {
filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
userId: string; userId: string;
}): Promise<{ }): Promise<{
@ -1187,9 +1217,11 @@ export class PortfolioService {
orders: OrderWithAccount[]; orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency =
this.request.user?.Settings?.currency ?? this.baseCurrency;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters,
includeDrafts, includeDrafts,
userCurrency, userCurrency,
userId, userId,
@ -1233,21 +1265,42 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
return { return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders, orders,
portfolioOrders portfolioOrders,
transactionPoints: portfolioCalculator.getTransactionPoints()
}; };
} }
private async getValueOfAccounts( private async getValueOfAccounts({
orders: OrderWithAccount[], filters = [],
portfolioItemsNow: { [p: string]: TimelinePosition }, orders,
userCurrency: string, portfolioItemsNow,
userId: string userCurrency,
) { userId
}: {
filters?: Filter[];
orders: OrderWithAccount[];
portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string;
userId: string;
}) {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const currentAccounts = await this.accountService.getAccounts(userId); let currentAccounts = [];
if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId);
} else {
const accountIds = uniq(
orders.map(({ accountId }) => {
return accountId;
})
);
currentAccounts = await this.accountService.accounts({
where: { id: { in: accountIds } }
});
}
for (const account of currentAccounts) { for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => { const ordersByAccount = orders.filter(({ accountId }) => {
@ -1257,34 +1310,47 @@ export class PortfolioService {
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
current: account.balance, current: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
name: account.name, name: account.name,
original: account.balance original: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
}; };
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = let currentValueOfSymbolInBaseCurrency =
order.quantity * order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice; portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice; let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
userCurrency
);
if (order.type === 'SELL') { if (order.type === 'SELL') {
currentValueOfSymbol *= -1; currentValueOfSymbolInBaseCurrency *= -1;
originalValueOfSymbol *= -1; originalValueOfSymbolInBaseCurrency *= -1;
} }
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) { if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current += accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol; currentValueOfSymbolInBaseCurrency;
accounts[order.Account?.id || UNKNOWN_KEY].original += accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol; originalValueOfSymbolInBaseCurrency;
} else { } else {
accounts[order.Account?.id || UNKNOWN_KEY] = { accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbolInBaseCurrency,
name: account.name, name: account.name,
original: originalValueOfSymbol original: originalValueOfSymbolInBaseCurrency
}; };
} }
} }

View File

@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service';
useFactory: async (configurationService: ConfigurationService) => ({ useFactory: async (configurationService: ConfigurationService) => ({
host: configurationService.get('REDIS_HOST'), host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'), port: configurationService.get('REDIS_PORT'),
store: redisStore, store: redisStore,
ttl: configurationService.get('CACHE_TTL') ttl: configurationService.get('CACHE_TTL')

View File

@ -1,9 +1,7 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem extends UniqueAsset {
currency: string; currency: string;
dataSource: DataSource;
historicalData: HistoricalDataItem[]; historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,3 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import {
IDataGatheringItem, IDataGatheringItem,
@ -6,6 +5,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
@ -55,7 +55,8 @@ export class SymbolService {
currency, currency,
historicalData, historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}; };
} }

View File

@ -1,4 +0,0 @@
export interface Access {
alias?: string;
id: string;
}

View File

@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
locale?: string; locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
} }

View File

@ -34,7 +34,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService

View File

@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -19,7 +20,8 @@ import { UserService } from './user.service';
}), }),
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule,
TagModule
], ],
providers: [UserService] providers: [UserService]
}) })

View File

@ -2,18 +2,14 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import { TagService } from '@ghostfolio/api/services/tag/tag.service';
PROPERTY_IS_READ_ONLY_MODE, import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
@ -26,12 +22,17 @@ const crypto = require('crypto');
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = 'USD'; public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
) {} private readonly tagService: TagService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getUser( public async getUser(
{ {
@ -51,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } }, orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return { return {
alias, alias,
id, id,
permissions, permissions,
subscription, subscription,
tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,
@ -92,19 +102,69 @@ export class UserService {
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prismaService.user.findUnique({ const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
thirdPartyId,
updatedAt
};
let currentPermissions = getPermissions(userFromDatabase.role); if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex); currentPermissions.push(permissions.accessFearAndGreedIndex);
} }
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode); currentPermissions.push(permissions.toggleReadOnlyMode);
@ -125,29 +185,7 @@ export class UserService {
} }
} }
user.permissions = currentPermissions; user.permissions = currentPermissions.sort();
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (userFromDatabase) {
// Set default settings if needed
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
}
return user; return user;
} }
@ -186,14 +224,14 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: baseCurrency, currency: this.baseCurrency,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
}, },
Settings: { Settings: {
create: { create: {
currency: baseCurrency currency: this.baseCurrency
} }
} }
} }

View File

@ -0,0 +1,50 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
if (hasImpersonationId) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
data.accounts[accountId].balance = null;
}
}
}
if (data.activities) {
data.activities = data.activities.map((activity: Activity) => {
if (activity.Account?.balance !== undefined) {
activity.Account.balance = null;
}
return activity;
});
}
}
return data;
})
);
}
}

View File

@ -12,6 +12,7 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
@ -24,6 +25,7 @@ export class ConfigurationService {
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
@ -35,6 +37,7 @@ export class ConfigurationService {
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),

View File

@ -1,5 +1,11 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -8,6 +14,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
@ -30,6 +38,13 @@ export class CronService {
@Cron(CronExpression.EVERY_WEEKEND) @Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() { public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
} }

View File

@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module'; import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule, ConfigurationModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringModule {}

View File

@ -0,0 +1,27 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { DataGatheringService } from './data-gathering.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
}
}
}

View File

@ -226,31 +226,33 @@ export class DataGatheringService {
} }
} }
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
Logger.log( let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
'Profile data gathering has been started.', return dataGatheringItem.dataSource !== 'MANUAL';
'DataGatheringService' });
);
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems?.filter( if (!uniqueAssets) {
(dataGatheringItem) => { uniqueAssets = await this.getUniqueAssets();
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
} }
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems uniqueAssets
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
); );
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -322,10 +324,13 @@ export class DataGatheringService {
} }
Logger.log( Logger.log(
'Profile data gathering has been completed.', `Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService' 'DataGatheringService'
); );
console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -377,7 +382,14 @@ export class DataGatheringService {
data: { data: {
dataSource, dataSource,
symbol, symbol,
date: currentDate, date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice marketPrice: lastMarketPrice
} }
}); });
@ -501,6 +513,27 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService'); Logger.log('Data gathering has been reset.', 'DataGatheringService');
@ -537,6 +570,7 @@ export class DataGatheringService {
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate } date: { gt: startDate }
} }
@ -576,27 +610,6 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();

View File

@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const holdings = await getJSON( const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => { ).catch(() => {
return getJSON( return getJSON(
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
); );
}); });
if (result.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
if ( if (
!response.countries || !response.countries ||
(response.countries as unknown as Country[]).length === 0 (response.countries as unknown as Country[]).length === 0
) { ) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) { for (const [name, value] of Object.entries<any>(result.countries)) {
let countryCode: string; let countryCode: string;
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
@ -75,7 +80,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0 (response.sectors as unknown as Sector[]).length === 0
) { ) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) { for (const [name, value] of Object.entries<any>(result.sectors)) {
response.sectors.push({ response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight weight: value.weight

View File

@ -1,5 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
@Module({ @Module({
@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service';
providers: [ providers: [
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
EodHistoricalDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces', provide: 'DataProviderInterfaces',
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,
@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service';
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,

View File

@ -0,0 +1,138 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const get = bent(
`${this.URL}/eod/${aSymbol}?api_token=${
this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to,
DATE_FORMAT
)}&period={aGranularity}`,
'GET',
'json',
200
);
const response = await get();
return response.reduce(
(result, historicalItem, index, array) => {
result[aSymbol][historicalItem.date] = {
marketPrice: historicalItem.close,
performance: historicalItem.open - historicalItem.close
};
return result;
},
{ [aSymbol]: {} }
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
return {};
}
public getName(): DataSource {
return DataSource.EOD_HISTORICAL_DATA;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/real-time/${aSymbols[0]}?api_token=${
this.apiKey
}&fmt=json&s=${aSymbols.join(',')}`,
'GET',
'json',
200
);
const [response, symbolProfiles] = await Promise.all([
get(),
this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return {
symbol,
dataSource: DataSource.EOD_HISTORICAL_DATA
};
})
)
]);
const quotes = aSymbols.length === 1 ? [response] : response;
return quotes.reduce((result, item, index, array) => {
result[item.code] = {
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === item.code;
})?.currency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: item.close,
marketState: 'delayed'
};
return result;
}, {});
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}
}

View File

@ -2,8 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -11,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
@ -47,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbol; const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] =
[symbol] await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
);
const { defaultMarketPrice, selector, url } = const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration; symbolProfile.scraperConfiguration;
@ -109,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles =
aSymbols await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
);
const marketData = await this.prismaService.marketData.findMany({ const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'], distinct: ['symbol'],
@ -133,7 +130,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice: marketData.find((marketDataItem) => { marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol; return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice, }).marketPrice,
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -92,9 +91,8 @@ export class GoogleSheetsService implements DataProviderInterface {
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles =
aSymbols await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
);
const sheet = await this.getSheet({ const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'), sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
@ -114,7 +112,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return symbolProfile.symbol === symbol; return symbolProfile.symbol === symbol;
})?.currency, })?.currency,
dataSource: this.getName(), dataSource: this.getName(),
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
@ -12,7 +11,7 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable() @Injectable()
@ -118,7 +117,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
currency: undefined, currency: undefined,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open marketState: 'open'
} }
}; };
} }

View File

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

View File

@ -1,12 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -24,9 +23,14 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -34,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace( const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`), new RegExp(`-${this.baseCurrency}$`),
baseCurrency this.baseCurrency
); );
return symbol.replace('=X', ''); return symbol.replace('=X', '');
} }
@ -48,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface {
* DOGEUSD -> DOGE-USD * DOGEUSD -> DOGE-USD
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) { if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) { if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
) )
) { ) {
// Add a dash before the last three characters // Add a dash before the last three characters
@ -61,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace( return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`), new RegExp(`-?${this.baseCurrency}$`),
`-${baseCurrency}` `-${this.baseCurrency}`
); );
} }
} }
@ -89,8 +96,12 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency; response.currency = assetProfile.price.currency;
response.dataSource = this.getName(); response.dataSource = this.getName();
response.name = response.name = this.formatName({
assetProfile.price.longName || assetProfile.price.shortName || symbol; longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol; response.symbol = aSymbol;
if ( if (
@ -212,8 +223,8 @@ export class YahooFinanceService implements DataProviderInterface {
marketState: marketState:
quote.marketState === 'REGULAR' || quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? 'open'
: MarketState.closed, : 'closed',
marketPrice: quote.regularMarketPrice || 0 marketPrice: quote.regularMarketPrice || 0
}; };
@ -245,23 +256,29 @@ export class YahooFinanceService implements DataProviderInterface {
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
// filter out undefined symbols // Filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
return ( return (
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) symbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)) || )) ||
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType) ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency); return symbol.includes(this.baseCurrency);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;
} }
return true; return true;
@ -286,7 +303,12 @@ export class YahooFinanceService implements DataProviderInterface {
symbol, symbol,
currency: marketDataItem.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol name: this.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
symbol: quote.symbol
})
}); });
} }
} catch (error) { } catch (error) {
@ -296,6 +318,40 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
}
private parseAssetClass(aPrice: Price): { private parseAssetClass(aPrice: Price): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
@ -315,6 +371,20 @@ export class YahooFinanceService implements DataProviderInterface {
case 'etf': case 'etf':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF; assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break; break;
case 'mutualfund': case 'mutualfund':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;

View File

@ -1,12 +1,18 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule, PropertyModule], imports: [
ConfigurationModule,
DataProviderModule,
PrismaModule,
PropertyModule
],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -1,9 +1,10 @@
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
} }
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
} }
public getCurrencyPairs() { public getCurrencyPairs() {
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
} }
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies return aCurrencies
.filter((currency) => { .filter((currency) => {
return currency !== baseCurrency; return currency !== this.baseCurrency;
}) })
.map((currency) => { .map((currency) => {
return { return {
currency1: baseCurrency, currency1: this.baseCurrency,
currency2: currency, currency2: currency,
dataSource: this.dataProviderService.getPrimaryDataSource(), dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}` symbol: `${this.baseCurrency}${currency}`
}; };
}); });
} }

View File

@ -3,6 +3,7 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors { export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string; DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
@ -15,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
EOD_HISTORICAL_DATA_API_KEY: string;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string; GOOGLE_SHEETS_ACCOUNT: string;
@ -26,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors {
PORT: number; PORT: number;
RAKUTEN_RAPID_API_KEY: string; RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_PUBLIC_KEY: string; STRIPE_PUBLIC_KEY: string;

View File

@ -1,18 +1,11 @@
import { MarketState } from '@ghostfolio/common/types';
import { import {
Account, Account,
AssetClass,
AssetSubClass,
DataSource, DataSource,
SymbolProfile, SymbolProfile,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export interface IOrder { export interface IOrder {
account: Account; account: Account;
currency: string; currency: string;
@ -44,5 +37,3 @@ export interface IDataGatheringItem {
date?: Date; date?: Date;
symbol: string; symbol: string;
} }
export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -34,6 +34,20 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
marketPrice: true
},
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({ public async getRange({
dateQuery, dateQuery,
symbols symbols

View File

@ -1,14 +1,21 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
EnhancedSymbolProfile,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -32,10 +39,40 @@ export class SymbolProfileService {
} }
public async getSymbolProfiles( public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
include: { SymbolProfileOverrides: true },
where: {
AND: [
{
dataSource: {
in: aUniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
/**
* @deprecated
*/
public async getSymbolProfilesBySymbols(
symbols: string[] symbols: string[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true },
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -45,30 +82,66 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] { private getSymbols(
return symbolProfiles.map((symbolProfile) => ({ symbolProfiles: (SymbolProfile & {
...symbolProfile, SymbolProfileOverrides: SymbolProfileOverrides;
countries: this.getCountries(symbolProfile), })[]
scraperConfiguration: this.getScraperConfiguration(symbolProfile), ): EnhancedSymbolProfile[] {
sectors: this.getSectors(symbolProfile), return symbolProfiles.map((symbolProfile) => {
symbolMapping: this.getSymbolMapping(symbolProfile) const item = {
})); ...symbolProfile,
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
};
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides
?.countries as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
delete item.SymbolProfileOverrides;
}
return item;
});
} }
private getCountries(symbolProfile: SymbolProfile): Country[] { private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map( if (aCountries === null) {
(country) => { return [];
const { code, weight } = country as Prisma.JsonObject; }
return { return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
code: code as string, const { code, weight } = country;
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, return {
name: countries[code as string]?.name ?? UNKNOWN_KEY, code,
weight: weight as number weight,
}; continent:
} continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
); name: countries[code as string]?.name ?? UNKNOWN_KEY
};
});
} }
private getScraperConfiguration( private getScraperConfiguration(

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { TagService } from './tag.service';
@Module({
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
}
});
}
public async getByUser(userId: string) {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
},
where: {
orders: {
some: {
userId
}
}
}
});
}
}

View File

@ -1,11 +1,13 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({
exports: [TwitterBotService], exports: [TwitterBotService],
imports: [ConfigurationModule, SymbolModule], imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
providers: [TwitterBotService] providers: [TwitterBotService]
}) })
export class TwitterBotModule {} export class TwitterBotModule {}

View File

@ -1,12 +1,19 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; import {
resolveFearAndGreedIndex,
resolveMarketCondition
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns'; import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
@ -14,7 +21,9 @@ export class TwitterBotService {
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) { ) {
this.twitterClient = new TwitterApi({ this.twitterClient = new TwitterApi({
@ -30,7 +39,7 @@ export class TwitterBotService {
public async tweetFearAndGreedIndex() { public async tweetFearAndGreedIndex() {
if ( if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date()) isWeekend(new Date())
) { ) {
return; return;
} }
@ -48,7 +57,16 @@ export class TwitterBotService {
symbolItem.marketPrice symbolItem.marketPrice
); );
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`; let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
const benchmarkListing = await this.getBenchmarkListing(3);
if (benchmarkListing?.length > 1) {
status += '\n\n';
status += '±% from ATH\n';
status += benchmarkListing;
}
const { data: createdTweet } = await this.twitterClient.v2.tweet( const { data: createdTweet } = await this.twitterClient.v2.tweet(
status status
); );
@ -62,4 +80,35 @@ export class TwitterBotService {
Logger.error(error, 'TwitterBotService'); Logger.error(error, 'TwitterBotService');
} }
} }
private async getBenchmarkListing(aMax: number) {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const benchmarks = await this.benchmarkService.getBenchmarks(
benchmarkAssets
);
const benchmarkListing: string[] = [];
for (const [index, benchmark] of benchmarks.entries()) {
if (index > aMax - 1) {
break;
}
benchmarkListing.push(
`${benchmark.name} ${(
benchmark.performances.allTimeHigh.performancePercent * 100
).toFixed(1)}%${
benchmark.marketCondition !== 'NEUTRAL_MARKET'
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
: ''
}`
);
}
return benchmarkListing.join('\n');
}
} }

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2015"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -5,5 +5,5 @@
"module": "commonjs", "module": "commonjs",
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'client', displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
transform: { transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'] transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
}; };

View File

@ -1,20 +1,28 @@
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { format, isValid } from 'date-fns'; import { getDateFormatString } from '@ghostfolio/common/helper';
import * as deDateFnsLocale from 'date-fns/locale/de/index'; import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
/** /**
* @constructor * @constructor
*/ */
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform platform: Platform
) { ) {
super(matDateLocale, platform); super(matDateLocale, platform);
} }
/**
* Formats a date as a string
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, getDateFormatString(this.locale));
}
/** /**
* Sets the first day of the week to Monday * Sets the first day of the week to Monday
*/ */
@ -22,44 +30,10 @@ export class CustomDateAdapter extends NativeDateAdapter {
return 1; return 1;
} }
/**
* Formats a date as a string according to the given format
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, aParseFormat, {
locale: <any>deDateFnsLocale
});
}
/** /**
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: any): Date { public parse(aValue: string): Date {
let date: Date; return parse(aValue, getDateFormatString(this.locale), new Date());
try {
// TODO
// Native date parser from the following formats:
// - 'd.M.yyyy'
// - 'dd.MM.yyyy'
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
const [, day, month, year] = datePattern.exec(aValue);
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // monthIndex
parseInt(day, 10)
);
} catch (error) {
} finally {
const isDateValid = date && isValid(date);
if (isDateValid) {
return date;
}
return null;
}
} }
} }

View File

@ -200,7 +200,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>

View File

@ -2,8 +2,10 @@
<gf-line-chart <gf-line-chart
class="mb-4" class="mb-4"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[locale]="locale"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[symbol]="symbol"
></gf-line-chart> ></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex"> <div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div> <div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>

View File

@ -8,11 +8,13 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getDateFormatString, getDateFormatString,
getLocale getLocale
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
@ -53,14 +55,24 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number }; [day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
}; };
} = {}; } = {};
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog private dialog: MatDialog,
private userService: UserService
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
} }
public ngOnInit() {} public ngOnInit() {}
@ -145,7 +157,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
date, date,
marketPrice, marketPrice,
dataSource: this.dataSource, dataSource: this.dataSource,
symbol: this.symbol symbol: this.symbol,
user: this.user
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -1,3 +1,4 @@
import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams { export interface MarketDataDetailDialogParams {
@ -5,4 +6,5 @@ export interface MarketDataDetailDialogParams {
date: Date; date: Date;
marketPrice: number; marketPrice: number;
symbol: string; symbol: string;
user: User;
} }

View File

@ -5,6 +5,7 @@ import {
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -24,11 +25,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>, public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}
public ngOnInit() {} public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
}
public onCancel(): void { public onCancel(): void {
this.dialogRef.close({ withRefresh: false }); this.dialogRef.close({ withRefresh: false });

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
@ -15,6 +16,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public user: User;
public users: AdminData['users']; public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -24,8 +26,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -45,14 +45,27 @@
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }} {{ formatDistanceToNow(userItem.createdAt) }}
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.accountCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.transactionCount }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2">
{{ userItem.engagement | number: '1.0-0' }} <gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"
></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.lastActivity) }} {{ formatDistanceToNow(userItem.lastActivity) }}

View File

@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component'; import { AdminUsersComponent } from './admin-users.component';
@NgModule({ @NgModule({
declarations: [AdminUsersComponent], declarations: [AdminUsersComponent],
exports: [], exports: [],
imports: [CommonModule, MatButtonModule, MatMenuModule], imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminUsersModule {} export class GfAdminUsersModule {}

View File

@ -18,6 +18,8 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'], styleUrls: ['./home-holdings.scss'],
@ -126,12 +128,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: <PositionDetailDialogParams>{
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -15,6 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-market.html' templateUrl: './home-market.html'
}) })
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
@ -70,6 +75,15 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
}); });
} }
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });

View File

@ -1,18 +1,19 @@
<div <div class="container">
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100" <h3 class="mb-3 text-center" i18n>Markets</h3>
> <div class="mb-5 row">
<div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted"> <div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small> <small i18n>Last {{ numberOfDays }} Days</small>
</div> </div>
<gf-line-chart <gf-line-chart
class="mb-5" class="mb-3"
symbol="Fear & Greed Index"
yMax="100" yMax="100"
yMaxLabel="Greed" yMaxLabel="Greed"
yMin="0" yMin="0"
yMinLabel="Fear" yMinLabel="Fear"
[historicalDataItems]="historicalData" [historicalDataItems]="historicalData"
[locale]="user?.settings?.locale"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
></gf-line-chart> ></gf-line-chart>
@ -23,4 +24,20 @@
></gf-fear-and-greed-index> ></gf-fear-and-greed-index>
</div> </div>
</div> </div>
<div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
*ngFor="let benchmark of benchmarks"
class="py-2"
[benchmark]="benchmark"
[locale]="user?.settings?.locale"
></gf-benchmark>
<gf-benchmark
*ngIf="!benchmarks"
class="py-2"
[benchmark]="undefined"
></gf-benchmark>
</div>
</div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule], imports: [
CommonModule,
GfBenchmarkModule,
GfFearAndGreedIndexModule,
GfLineChartModule
],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -6,6 +6,7 @@
<gf-line-chart <gf-line-chart
symbol="Performance" symbol="Performance"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }" [ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true" [showGradient]="true"
[showLoader]="false" [showLoader]="false"

View File

@ -6,11 +6,18 @@ import {
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate, parseDate,
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -21,7 +28,8 @@ import {
LineElement, LineElement,
LinearScale, LinearScale,
PointElement, PointElement,
TimeScale TimeScale,
Tooltip
} from 'chart.js'; } from 'chart.js';
import { addDays, isAfter, parseISO, subDays } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@ -32,9 +40,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
styleUrls: ['./investment-chart.component.scss'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -47,8 +57,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
TimeScale TimeScale,
Tooltip
); );
Tooltip.positioners['top'] = (elements, position) =>
getTooltipPositionerMapTop(this.chart, position);
} }
public ngOnChanges() { public ngOnChanges() {
@ -98,6 +112,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
data: this.investments.map((position) => { data: this.investments.map((position) => {
return position.investment; return position.investment;
}), }),
label: 'Investment',
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context: unknown) =>
this.isInFuture( this.isInFuture(
@ -114,6 +129,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -124,13 +142,20 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
tension: 0 tension: 0
}, },
point: { point: {
hoverBackgroundColor: getBackgroundColor(),
hoverRadius: 2,
radius: 0 radius: 0
} }
}, },
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: <unknown>{
legend: { legend: {
display: false display: false
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
} }
}, },
responsive: true, responsive: true,
@ -138,16 +163,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
x: { x: {
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false
}, },
type: 'time', type: 'time',
time: { time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year' unit: 'year'
} }
}, },
y: { y: {
display: !this.isInPercent, display: !this.isInPercent,
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false
}, },
ticks: { ticks: {
@ -161,6 +191,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
} }
}, },
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line' type: 'line'
}); });
@ -169,6 +200,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
} }
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions(
this.isInPercent ? undefined : this.currency,
this.isInPercent ? undefined : this.locale
),
mode: 'index',
position: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
private isInFuture<T>(aContext: any, aValue: T) { private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue ? aValue

View File

@ -5,6 +5,7 @@ export interface PositionDetailDialogParams {
dataSource: DataSource; dataSource: DataSource;
deviceType: string; deviceType: string;
hasImpersonationId: boolean; hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
locale: string; locale: string;
symbol: string; symbol: string;
} }

View File

@ -9,9 +9,10 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile } from '@prisma/client'; import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -44,10 +45,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public SymbolProfile: SymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public transactionCount: number; public transactionCount: number;
public value: number; public value: number;
@ -83,12 +86,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
transactionCount, transactionCount,
value value
}) => { }) => {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance; this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent; this.grossPerformancePercent = grossPerformancePercent;
@ -115,6 +120,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.quantity = quantity; this.quantity = quantity;
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.value = value; this.value = value;

View File

@ -23,7 +23,9 @@
class="mb-4" class="mb-4"
benchmarkLabel="Average Unit Price" benchmarkLabel="Average Unit Price"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[currency]="SymbolProfile?.currency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[locale]="data.locale"
[showGradient]="true" [showGradient]="true"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
@ -168,7 +170,7 @@
</ng-container> </ng-container>
<ng-template #charts> <ng-template #charts>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -179,7 +181,7 @@
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h4" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
@ -192,23 +194,49 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
</div>
<gf-activities-table <div *ngIf="orders?.length > 0" class="row">
*ngIf="orders?.length > 0" <div class="col mb-3">
[activities]="orders" <div class="h5 mb-0" i18n>Activities</div>
[baseCurrency]="data.baseCurrency" <gf-activities-table
[deviceType]="data.deviceType" [activities]="orders"
[hasPermissionToCreateActivity]="false" [baseCurrency]="data.baseCurrency"
[hasPermissionToExportActivities]="!hasImpersonationId" [deviceType]="data.deviceType"
[hasPermissionToFilter]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToImportActivities]="false" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToOpenDetails]="false" [hasPermissionToFilter]="false"
[locale]="data.locale" [hasPermissionToImportActivities]="false"
[showActions]="false" [hasPermissionToOpenDetails]="false"
[showSymbolColumn]="false" [locale]="data.locale"
(export)="onExport()" [showActions]="false"
></gf-activities-table> [showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col mb-3">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list>
</div>
</div>
<div
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
</div>
</div>
</div>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
@ -24,6 +25,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDialogModule, MatDialogModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -73,11 +73,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
/*public applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void { public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true } queryParams: { dataSource, symbol, positionDetailDialog: true }

View File

@ -5,7 +5,6 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
@Component({ @Component({
@ -42,10 +41,7 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = []; this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) { for (const portfolioPosition of this.positions) {
if ( if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'
) {
// Only show positions where the market is open in today's view // Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition); this.positionsWithPriority.push(portfolioPosition);
} else { } else {

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -17,12 +16,10 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;
public version = environment.version; public version = environment.version;

View File

@ -2,103 +2,101 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="about-container"> <div class="about-container">
<mat-card-content> <p>
<p> <strong>Ghostfolio</strong> is a lightweight wealth management
<strong>Ghostfolio</strong> is a lightweight wealth management application for individuals to keep track of stocks, ETFs or
application for individuals to keep track of their wealth like cryptocurrencies and make solid, data-driven investment decisions. The
stocks, ETFs or cryptocurrencies and make solid, data-driven source code is fully available as open source software (OSS). The
investment decisions. The source code is fully available as open project has been initiated by
source software (OSS). The project has been initiated by <a href="https://dotsilver.ch" title="Website of Thomas Kaul"
<a href="https://dotsilver.ch" title="Website of Thomas Kaul" >Thomas Kaul</a
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new <a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
> >
<div and is driven by the efforts of its
class="independent-and-bootstrapped-logo mb-2" <a
title="Ghostfolio is an independent & bootstrapped business" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
></div> title="Contributors to Ghostfolio"
</div> >contributors</a
</mat-card-content> >.
</mat-card> <ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</div>
</div> </div>
</div> </div>
@ -109,38 +107,39 @@
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 24 hours)</small subLabel="(Last 24 hours)"
> [value]="statistics?.activeUsers1d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="New Users"
<span i18n>New Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.newUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.activeUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a <a
class="d-block" class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.slackCommunityUsers ?? '-' }} label="Users in Slack community"
</h3> size="large"
<div class="h6 mb-0" i18n>Users in Slack community</div> [value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -148,10 +147,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.gitHubContributors ?? '-' }} label="Contributors on GitHub"
</h3> size="large"
<div class="h6 mb-0" i18n>Contributors on GitHub</div> [value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -159,8 +159,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3> <gf-value
<div class="h6 mb-0" i18n>Stars on GitHub</div> label="Stars on GitHub"
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [ imports: [
AboutPageRoutingModule, AboutPageRoutingModule,
CommonModule, CommonModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule
], ],

View File

@ -2,15 +2,13 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card { .about-container {
&.about-container { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
} }
} }
@ -29,7 +27,7 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.mat-card { .about-container {
.independent-and-bootstrapped-logo { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg'); background-image: url('/assets/bootstrapped-light.svg');
opacity: 1; opacity: 1;

View File

@ -20,7 +20,6 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -43,7 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle; signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[]; public accesses: Access[];
public baseCurrency = baseCurrency; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public currencies: string[] = []; public currencies: string[] = [];
@ -79,8 +78,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private userService: UserService, private userService: UserService,
public webAuthnService: WebAuthnService public webAuthnService: WebAuthnService
) { ) {
const { currencies, globalPermissions, subscriptions } = const { baseCurrency, currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo(); this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.[0]?.coupon; this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId; this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies; this.currencies = currencies;

View File

@ -47,9 +47,21 @@
<strong>personal investment strategy</strong>. <strong>personal investment strategy</strong>.
</h2> </h2>
<p class="lead"> <p class="lead">
<strong>Ghostfolio</strong> empowers busy people to keep track of their <strong>Ghostfolio</strong> empowers busy people to keep track of
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven stocks, ETFs or cryptocurrencies and make solid, data-driven investment
investment decisions. decisions.
</p>
<p>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -8,16 +9,18 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
Filter,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
UniqueAsset, UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -32,6 +35,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public continents: { public continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
@ -39,7 +44,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public deviceType: string; public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public isLoading = false;
public markets: { public markets: {
[key in Market]: { name: string; value: number }; [key in Market]: { name: string; value: number };
}; };
@ -48,6 +55,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' } { label: 'Current', value: 'current' }
]; ];
public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
public positions: { public positions: {
[symbol: string]: Pick< [symbol: string]: Pick<
@ -76,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public user: User; public user: User;
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/** /**
@ -120,14 +129,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.dataService this.filters$
.fetchPortfolioDetails({}) .pipe(
.pipe(takeUntil(this.unsubscribeSubject)) distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => { .subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails; this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData(this.period); this.initializeAnalysisData(this.period);
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -137,12 +160,47 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts
.filter(({ accountType }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return {
id,
label: name,
type: 'ACCOUNT'
};
});
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: name,
type: 'TAG'
};
});
this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public initializeAnalysisData(aPeriod: string) { public initialize() {
this.accounts = {}; this.accounts = {};
this.continents = { this.continents = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
@ -185,6 +243,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
}
public initializeAnalysisData(aPeriod: string) {
this.initialize();
for (const [id, { current, name, original }] of Object.entries( for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts this.portfolioDetails.accounts
@ -305,14 +367,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
if (position.assetClass === AssetClass.EQUITY) { this.symbols[prettifySymbol(symbol)] = {
this.symbols[prettifySymbol(symbol)] = { dataSource: position.dataSource,
dataSource: position.dataSource, name: position.name,
name: position.name, symbol: prettifySymbol(symbol),
symbol: prettifySymbol(symbol), value: aPeriod === 'original' ? position.investment : position.value
value: aPeriod === 'original' ? position.investment : position.value };
};
}
} }
const marketsTotal = const marketsTotal =
@ -362,12 +422,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: <PositionDetailDialogParams>{
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -2,6 +2,12 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">
@ -89,7 +95,7 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate" <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Symbol</span ><span i18n>By Position</span
><ion-icon ><ion-icon
*ngIf="user?.subscription?.type === 'Basic'" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [ imports: [
AllocationsPageRoutingModule, AllocationsPageRoutingModule,
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,

View File

@ -2,21 +2,17 @@
<div class="investment-chart row"> <div class="investment-chart row">
<div class="col-lg"> <div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<mat-card class="mb-3"> <div class="mb-3">
<mat-card-header> <div class="h5 mb-3" i18n>Investment Timeline</div>
<mat-card-title class="align-items-center d-flex" i18n <gf-investment-chart
>Investment Timeline</mat-card-title class="h-100"
> [currency]="user?.settings?.baseCurrency"
</mat-card-header> [daysInMarket]="daysInMarket"
<mat-card-content> [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
<gf-investment-chart [investments]="investments"
class="h-100" [locale]="user?.settings?.locale"
[daysInMarket]="daysInMarket" ></gf-investment-chart>
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" </div>
[investments]="investments"
></gf-investment-chart>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js'; import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -16,6 +17,7 @@ import { takeUntil } from 'rxjs/operators';
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public fireWealth: Big; public fireWealth: Big;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
@ -63,11 +65,23 @@ export class FirePageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -41,7 +41,7 @@
[value]="withdrawalRatePerMonth?.toNumber()" [value]="withdrawalRatePerMonth?.toNumber()"
></gf-value> ></gf-value>
per month</span per month</span
>, based on your investment of >, based on your total assets of
<gf-value <gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
@ -59,7 +59,10 @@
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()" [fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator> ></gf-fire-calculator>
</div> </div>
</div> </div>

View File

@ -8,12 +8,13 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject } from 'rxjs';
@ -39,26 +40,33 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass);
public assetSubClasses = Object.keys(AssetSubClass);
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators; public Validators = Validators;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>, public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
@ -66,6 +74,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required], accountId: [this.data.activity?.accountId, Validators.required],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
currency: [ currency: [
this.data.activity?.SymbolProfile?.currency, this.data.activity?.SymbolProfile?.currency,
Validators.required Validators.required
@ -85,10 +95,30 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}, },
Validators.required Validators.required
], ],
tags: [this.data.activity?.tags],
type: [undefined, Validators.required], // Set after value changes subscription type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required] unitPrice: [this.data.activity?.unitPrice, Validators.required]
}); });
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
) {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0;
}
});
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol' 'searchSymbol'
].valueChanges.pipe( ].valueChanges.pipe(
@ -100,9 +130,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable = const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query); this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => { filteredLookupItemsObservable
this.filteredLookupItems = filteredLookupItems; .pipe(takeUntil(this.unsubscribeSubject))
}); .subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable; return filteredLookupItemsObservable;
} }
@ -111,45 +143,47 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}) })
); );
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => { this.activityForm.controls['type'].valueChanges
if (type === 'ITEM') { .pipe(takeUntil(this.unsubscribeSubject))
this.activityForm.controls['accountId'].removeValidators( .subscribe((type: Type) => {
Validators.required if (type === 'ITEM') {
); this.activityForm.controls['accountId'].removeValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['currency'].setValue( );
this.data.user.settings.baseCurrency this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['currency'].setValue(
this.activityForm.controls['dataSource'].removeValidators( this.data.user.settings.baseCurrency
Validators.required );
); this.activityForm.controls['dataSource'].removeValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].setValidators(Validators.required); );
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['searchSymbol'].removeValidators( this.activityForm.controls['name'].updateValueAndValidity();
Validators.required this.activityForm.controls['quantity'].setValue(1);
); this.activityForm.controls['searchSymbol'].removeValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} else { );
this.activityForm.controls['accountId'].setValidators( this.activityForm.controls['searchSymbol'].updateValueAndValidity();
Validators.required } else {
); this.activityForm.controls['accountId'].setValidators(
this.activityForm.controls['accountId'].updateValueAndValidity(); Validators.required
this.activityForm.controls['dataSource'].setValidators( );
Validators.required this.activityForm.controls['accountId'].updateValueAndValidity();
); this.activityForm.controls['dataSource'].setValidators(
this.activityForm.controls['dataSource'].updateValueAndValidity(); Validators.required
this.activityForm.controls['name'].removeValidators( );
Validators.required this.activityForm.controls['dataSource'].updateValueAndValidity();
); this.activityForm.controls['name'].removeValidators(
this.activityForm.controls['name'].updateValueAndValidity(); Validators.required
this.activityForm.controls['searchSymbol'].setValidators( );
Validators.required this.activityForm.controls['name'].updateValueAndValidity();
); this.activityForm.controls['searchSymbol'].setValidators(
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); Validators.required
} );
}); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].setValue(this.data.activity?.type); this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -209,6 +243,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onSubmit() { public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = { const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value, accountId: this.activityForm.controls['accountId'].value,
assetClass: this.activityForm.controls['assetClass'].value,
assetSubClass: this.activityForm.controls['assetSubClass'].value,
currency: this.activityForm.controls['currency'].value, currency: this.activityForm.controls['currency'].value,
date: this.activityForm.controls['date'].value, date: this.activityForm.controls['date'].value,
dataSource: this.activityForm.controls['dataSource'].value, dataSource: this.activityForm.controls['dataSource'].value,

View File

@ -1,6 +1,7 @@
<form <form
class="d-flex flex-column h-100" class="d-flex flex-column h-100"
[formGroup]="activityForm" [formGroup]="activityForm"
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1> <h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
@ -134,13 +135,55 @@
> >
</mat-form-field> </mat-form-field>
</div> </div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass"
>{{ assetClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub-Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass"
>{{ assetSubClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-list>
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value">
{{ tag.name }}
</mat-chip>
</mat-chip-list>
</mat-form-field>
</div>
</div> </div>
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="activityForm.controls['currency'].value" [currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0" [value]="total"
></gf-value> ></gf-value>
<div> <div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

View File

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,6 +25,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
FormsModule, FormsModule,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatChipsModule,
MatDatepickerModule, MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

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