Compare commits

..

105 Commits

Author SHA1 Message Date
e29f7f8976 Release 1.112.1 (#683) 2022-02-06 21:41:51 +01:00
82069da4e2 Bugfix/fix user account creation (#682)
* Fix the user account creation

* Update changelog
2022-02-06 21:40:26 +01:00
07656c6a95 Release 1.112.0 (#681) 2022-02-06 17:18:28 +01:00
16f0743353 Bugfix/fix total value of activities table (#680)
* Fix total value (absolute value)

* Update changelog
2022-02-06 17:14:04 +01:00
9b5ec0c56d Feature/fix twr performance (#679)
* Fix TWR performance

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-06 16:54:14 +01:00
8d2fcc6b42 Feature/upgrade prisma to version 3.9.1 (#677)
* Upgrade prisma to version 3.9.1

* Update changelog
2022-02-06 15:47:08 +01:00
e625e55784 Move currency column (#678) 2022-02-06 15:46:14 +01:00
bed3e5aae2 Bugfix/fix horizontal overflow in activities table (#676)
* Fix horizontal overflow in tables

* Update changelog
2022-02-06 15:45:39 +01:00
65bfe52db4 Feature/simplify admin user sign up (#675)
* Simplify admin user sign up

* Update changelog
2022-02-06 09:32:41 +01:00
48b524de5a Feature/add export functionality to position detail dialog (#672)
* Add export functionality to the position detail dialog

* Respect filters in activities export

* Update changelog
2022-02-05 20:26:10 +01:00
67d40333f6 Move currency column (#674) 2022-02-05 10:17:09 +01:00
48f6b8d353 Release 1.111.0 (#671) 2022-02-03 21:00:53 +01:00
f369996912 Bugfix/fix symbol selection of 7d data gathering (#670)
* Fix symbol selection of 7d data gathering

* Update changelog
2022-02-03 20:58:59 +01:00
dc424a86ec Feature/support deleting symbol profile data (#669)
* Add support for deleting symbol profile data

* Update changelog
2022-02-03 20:56:39 +01:00
5d8bde5a70 Feature/access data source and symbol from symbol profile (#668)
* Access dataSource and symbol from SymbolProfile

* Update changelog
2022-02-03 19:21:55 +01:00
16360c0c67 Feature/minor code cleanup (#667)
* Sort imports

* Update changelog
2022-02-02 22:06:34 +01:00
526a6b2030 Release 1.110.0 (#665) 2022-02-02 20:31:23 +01:00
5000e9c79b Feature/update database schema of order (#664)
* Add schema migrations

* Update changelog
2022-02-02 20:29:19 +01:00
161cb82820 Bugfix/fix data source of fear and greed index (#663)
* Encode data source

* Update changelog
2022-02-02 20:07:33 +01:00
fed28f29d1 Release 1.109.0 (#662) 2022-02-01 21:05:14 +01:00
8bd9330acc Feature/improve usability of create or edit transaction dialog (#661)
* Move the fee to the bottom

* Update changelog
2022-02-01 20:35:44 +01:00
155c08d665 Transform data source (#658)
* Transform data source

* Update changelog
2022-02-01 20:35:25 +01:00
b8ad6d6662 Feature/improve import (#657)
* Improve import

* Update changelog

Co-Authored-By: Ronald Konjer <ronaldkonjer@gmail.com>
2022-02-01 19:12:00 +01:00
9d6977e3f7 Feature/support cryptocurrency mina protocol (#659)
* Support Mina Protocol (MINA-USD)

* Update changelog
2022-02-01 10:58:34 +01:00
919b20197f import csv with account name or id (#654)
* import csv with account id
2022-01-29 17:27:33 +01:00
62885ea890 Feature/improve consistent use of symbol with data source (#656)
* Improve the consistent use of symbol with dataSource

* Update changelog
2022-01-29 16:51:37 +01:00
035d8ad9eb Update copyright (#655) 2022-01-29 09:15:11 +01:00
9676f96e97 Release 1.108.0 (#653) 2022-01-27 21:33:59 +01:00
65e151151b Feature/increase fear and greed index to 90 days (#652)
* Increase fear and greed index to 90 days

* Update changelog
2022-01-27 21:01:38 +01:00
5d3bbb8f30 Feature/improve annualized performance (#651)
* Improve annualized performance calculation

* Update changelog
2022-01-27 20:56:20 +01:00
b464fefc57 Release 1.107.0 (#650) 2022-01-24 21:43:37 +01:00
bcb7f5f522 Feature/add feature toggle for new calculation engine (#649)
* Add feature toggle for new calculation engine

* Update changelog
2022-01-24 21:38:59 +01:00
f15b33e950 Portfolio calculator rework (#632)
* Portfolio calculator rework

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-01-24 20:35:13 +01:00
ca64492e77 Bugfix/fix styling in activities table footer (#648)
* Fix styling

* Update changelog
2022-01-24 20:12:54 +01:00
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
313d2a2f79 Release 1.99.0 (#612) 2022-01-01 16:40:55 +01:00
9ac67b0af2 Feature/expose profile data gathering by symbol endpoint (#611)
* Expose profile data gathering by symbol endpoint

* Update changelog
2022-01-01 16:18:18 +01:00
1e526852a7 Bugfix/fix mapping for russia in trackinsight data enhancer (#610)
* Fix mapping for Russia

* Update changelog
2022-01-01 13:55:53 +01:00
e54638a684 Feature/improve analysis page (#609)
* Improve analysis page (show y-axis, extend chart in relation to days in market)

* Update changelog
2022-01-01 12:09:49 +01:00
0179823ad9 Feature/restructure about page (#608)
* Restructure about page: introduce pages for blog and changelog

* Update changelog
2022-01-01 10:10:37 +01:00
029b7bed9a Bugfix/improve error handling in position api endpoint (#607)
* Add guards

* Update changelog
2021-12-31 22:00:58 +01:00
635f10e2d0 Bugfix/hide data provider warning while loading (#605)
* Hide data provider warning while loading

* Update changelog
2021-12-31 10:21:41 +01:00
cebf879d67 Feature/refactor demo user (#604)
* Refactor demo user id

* Update changelog
2021-12-31 09:52:03 +01:00
124bdc028d Bugfix/fix reload of position detail dialog (#603)
* Fix reload of position detail dialog

* Update changelog
2021-12-31 09:51:30 +01:00
d69a69ce18 Bugfix/fix exception with market state (#602)
* Fix exception with market state

* Update changelog
2021-12-30 22:19:08 +01:00
15344513ce Feature/upgrade chart.js to version 3.7.0 (#601)
* Upgrade chart.js from version 3.5.0 to 3.7.0

* Update changelog
2021-12-30 22:18:39 +01:00
b291d9e031 Feature/refactor transactions to activities table (#600)
* Refactor transactions to activities table with attributes and routes

* Update changelog
2021-12-30 18:56:51 +01:00
bee702302f Upgrade angular and Nx dependencies (#599) 2021-12-30 17:31:07 +01:00
bb56e09a13 Clean up preview feature flag (#598) 2021-12-30 10:15:30 +01:00
0873f539c5 Release 1.98.0 (#597) 2021-12-29 18:40:18 +01:00
6dcd801d05 Feature/extend statistics section with users in slack community (#596)
* Extend statistics with users in Slack community

* Update changelog
2021-12-29 18:38:55 +01:00
77065dac50 Feature/add date range selector to holdings tab (#595)
* Add date range selector to holdings tab

* Update changelog
2021-12-29 18:14:24 +01:00
438484879d Bugfix/fix creation of historical data (#594)
* Fix creation of historical data (upsert instead of update)

* Update changelog
2021-12-29 17:03:37 +01:00
e37a650c70 Bugfix/fix scrolling issue in position detail dialog on mobile (#593)
* Fix scrolling in position detail dialog on mobile

* Update changelog
2021-12-29 10:51:11 +01:00
6e8c90b3fc Release 1.97.0 (#592) 2021-12-28 21:40:10 +01:00
9e1a7fc981 Feature/dividend (#547)
* Add dividend to order type

* Support dividend in transactions table

* Support dividend in transaction dialog

* Extend import file with dividend

* Add dividend to portfolio summary

* Update changelog

Co-authored-by: Fly Man <fly.man.opensim@gmail.com>
2021-12-28 21:12:12 +01:00
ff638adf03 Feature/add transactions to position detail dialog (#591)
* Add transactions to position detail dialog

* Update changelog
2021-12-28 17:45:04 +01:00
fa44cee781 Release 1.96.0 (#590) 2021-12-27 21:08:33 +01:00
db1d474ddf Feature/more discreet data provider warning (#589)
* Upgrade http-status-codes to version 2.2.0

* Make the data provider warning more discreet

* Update changelog
2021-12-27 12:14:41 +01:00
994275e093 Feature/upgrade angular 3rd party dependencies (#588)
* Upgrade angular 3rd party dependencies
  * ngx-device-detector
  * ngx-markdown
  * ngx-stripe

* Update changelog
2021-12-26 21:58:56 +01:00
ee397c8047 Bugfix/fix file type detection for import (#587)
* Fix file type detection ("application/vnd.ms-excel" instead of "text/csv")

* Update changelog
2021-12-26 20:54:53 +01:00
7203939c42 Feature/upgrade prisma to version 3.7.0 (#586)
* Upgrade prisma from version 3.6.0 to 3.7.0

* Update changelog
2021-12-26 17:30:26 +01:00
9725f16c81 Clean up schema.prisma (#584) 2021-12-26 14:33:18 +01:00
bb8b1e4f43 Release 1.95.0 (#583) 2021-12-26 10:14:13 +01:00
9d3610331a Add guard (#582) 2021-12-26 10:07:51 +01:00
0043b44670 Feature/improve data gathering for currencies (#581)
* Improve data gathering for currencies, add warning if it fails

* Update changelog
2021-12-26 09:15:10 +01:00
bbc4e64cb4 Bugfix/filter currencies with null value (#579)
* Filter currencies with null value

* Update changelog
2021-12-25 17:08:56 +01:00
c7f4825499 Release 1.94.0 (#578) 2021-12-25 14:45:58 +01:00
8f583709ef Feature/add support for cosmos and polkadot (#577)
* Add support for cryptocurrencies ATOM and DOT

* Update changelog
2021-12-25 14:23:07 +01:00
4c30212a72 Feature/improve data gathering (#576)
* Eliminate benchmarks to gather

* Optimize 7d data gathering

* Update changelog
2021-12-25 14:18:46 +01:00
cade2f6a5e Feature/upgrade prettier to version 2.5.1 (#575)
* Upgrade prettier from version 2.3.2 to 2.5.1

* Update changelog
2021-12-25 10:29:56 +01:00
3b9a8fabb5 Clean up (#574) 2021-12-24 18:42:30 +01:00
3435b3a348 Feature/make the csv import more flexible (#573)
* Make the csv import more flexible

* Update changelog
2021-12-24 18:21:27 +01:00
5d39b267ab write portfolio calculator test case for symbol BALN.SW (refs #554) (#572) 2021-12-24 17:52:08 +01:00
ffaaa14dba Feature/increase fear and greed index to 30 days (#571)
* Increase Fear & Greed index to 30 days

* Update changelog
2021-12-24 09:40:24 +01:00
c65746d119 Simplify instructions for development setup (#570) 2021-12-22 18:09:00 +01:00
1a6840f1f6 Fix instruction for database setup (#568) 2021-12-21 20:59:01 +01:00
fb7fb886f6 Fix anchor link (#567) 2021-12-21 20:53:15 +01:00
190 changed files with 7941 additions and 2967 deletions

View File

@ -5,11 +5,275 @@ 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.112.1 - 06.02.2022
### Fixed
- Fixed the creation of the user account (missing access token)
## 1.112.0 - 06.02.2022
### Added
- Added the export functionality to the position detail dialog
### Changed
- Improved the export functionality for activities (respect filtering)
- Removed the _Admin_ user from the database seeding
- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
- Upgraded `prisma` from version `3.8.1` to `3.9.1`
### Fixed
- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
- Fixed the horizontal overflow in the accounts table
- Fixed the horizontal overflow in the activities table
- Fixed the total value of the activities table in the position detail dialog (absolute value)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.111.0 - 03.02.2022
### Added
- Added support for deleting symbol profile data in the admin control panel
### Changed
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
### Fixed
- Fixed the symbol selection of the 7d data gathering
## 1.110.0 - 02.02.2022
### Fixed
- Fixed the data source of the _Fear & Greed Index_ (market mood)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.109.0 - 01.02.2022
### Added
- Added support for the (optional) `accountId` in the import functionality for activities
- Added support for the (optional) `dataSource` in the import functionality for activities
- Added support for the data source transformation
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
### Changed
- Improved the usability of the form in the create or edit transaction dialog
- Improved the consistent use of `symbol` in combination with `dataSource`
- Removed the primary data source from the client
### Removed
- Removed the unused endpoint `GET api/order/:id`
## 1.108.0 - 27.01.2022
### Changed
- Improved the annualized performance in the new calculation engine
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
## 1.107.0 - 24.01.2022
### Added
- Added a new calculation engine (experimental)
### Fixed
- Fixed the styling in the footer row of the activities table
## 1.106.0 - 23.01.2022
### Added
- Added the footer row with total fees and total value to the activities table
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed
- Improved the redirection on logout
## 1.105.0 - 20.01.2022
### Added
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
### Changed
- Improved the data provider with grouping by data source and thereby reducing the number of requests
### Fixed
- Fixed the unresolved account names in the _X-ray_ section
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
## 1.104.0 - 16.01.2022
### Fixed
- Fixed the fallback to load currencies directly from the data provider
- Fixed the missing symbol profile data connection in the import functionality for activities
## 1.103.0 - 13.01.2022
### Changed
- Added links to the statistics section on the about page
### Fixed
- Fixed the currency of the value in the position detail dialog
## 1.102.0 - 11.01.2022
### Changed
- Start eliminating `dataSource` from activity
### Fixed
- Fixed the support for multiple accounts with the same name
- Fixed the preselected default account of the create activity dialog
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
- Added a blog post
### Fixed
- Fixed the routing of the create activity dialog
- Fixed the link color in the blog posts
## 1.99.0 - 01.01.2022
### Added
- Exposed the profile data gathering by symbol as an endpoint
### Changed
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
- Restructured the about page
- Start refactoring _transactions_ to _activities_
- Refactored the demo user id
- Upgraded `angular` from version `13.0.2` to `13.1.1`
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
### Fixed
- Hid the data provider warning while loading
- Fixed an exception with the market state caused by a failed data provider request
- Fixed an exception in the portfolio position endpoint
- Fixed the reload of the position detail dialog (with query parameters)
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
## 1.98.0 - 29.12.2021
### Added
- Added the date range component to the holdings tab
### Changed
- Extended the statistics section on the about page (users in Slack community)
### Fixed
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
- Fixed the scrolling issue in the position detail dialog on mobile
## 1.97.0 - 28.12.2021
### Added
- Added the transactions to the position detail dialog
- Added support for dividend
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.96.0 - 27.12.2021
### Changed
- Made the data provider warning more discreet
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
### Fixed
- Fixed the file type detection in the import functionality for transactions
## 1.95.0 - 26.12.2021
### Added
- Added a warning to the log if the data gathering fails
### Fixed
- Filtered potential `null` currencies
- Improved the 7d data gathering optimization for currencies
## 1.94.0 - 25.12.2021
### Added
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
- Made the import functionality for transactions by `csv` files more flexible
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
## 1.93.0 - 21.12.2021 ## 1.93.0 - 21.12.2021
### Added ### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`) - Added support for the cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio) - Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed ### Fixed

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</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/en/blog/2021/07/hello-ghostfolio"><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>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -34,7 +34,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker). If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
## Why Ghostfolio? ## Why Ghostfolio?
@ -95,6 +95,14 @@ Run the following command to start the Docker images from [Docker Hub](https://h
docker-compose -f docker/docker-compose.yml up docker-compose -f docker/docker-compose.yml up
``` ```
#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment ### b. Build and run environment
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
@ -104,28 +112,22 @@ docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up docker-compose -f docker/docker-compose.build.yml up
``` ```
### Setup Database #### Setup Database
Run the following command to setup the database once Ghostfolio is running: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
``` ```
### Fetch Historical Data ### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps: Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Finalization
1. Create a new user via _Get Started_
1. Assign the role `ADMIN` to this user (directly in the database)
1. Delete the original _Admin_ (directly in the database)
### Migrate Database ### Migrate Database
With the following command you can keep your database schema in sync after a Ghostfolio version update: With the following command you can keep your database schema in sync after a Ghostfolio version update:
@ -145,12 +147,10 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `cd docker` 1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
@ -181,6 +181,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
## License ## License
© 2021 [Ghostfolio](https://ghostfol.io) © 2022 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -1,4 +1,4 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
nullifyValuesInObject, nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -91,10 +91,9 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
let accountsWithAggregations = let accountsWithAggregations = await this.portfolioServiceStrategy
await this.portfolioService.getAccountsWithAggregations( .get()
impersonationUserId || this.request.user.id .getAccountsWithAggregations(impersonationUserId || this.request.user.id);
);
if ( if (
impersonationUserId || impersonationUserId ||

View File

@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
@ -96,6 +97,29 @@ export class AdminController {
return; return;
} }
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
}
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async gatherSymbol( public async gatherSymbol(
@ -172,9 +196,10 @@ export class AdminController {
return this.adminService.getMarketData(); return this.adminService.getMarketData();
} }
@Get('market-data/:symbol') @Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AdminMarketDataDetails> {
if ( if (
@ -189,7 +214,7 @@ export class AdminController {
); );
} }
return this.adminService.getMarketDataBySymbol(symbol); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@ -215,7 +240,7 @@ export class AdminController {
const date = new Date(dateString); const date = new Date(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data, data: { ...data, dataSource },
where: { where: {
date_symbol: { date_symbol: {
date, date,
@ -225,6 +250,27 @@ export class AdminController {
}); });
} }
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateProperty( public async updateProperty(

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule,
SymbolProfileModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],

View File

@ -5,14 +5,16 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; 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 { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client'; import { DataSource, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
@ -24,9 +26,21 @@ export class AdminService {
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
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 symbolProfileService: SymbolProfileService
) {} ) {}
public async deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
dataGatheringProgress: dataGatheringProgress:
@ -56,25 +70,85 @@ export class AdminService {
} }
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(): Promise<AdminMarketData> {
return { const marketData = await this.prismaService.marketData.groupBy({
marketData: await ( _count: true,
await this.dataGatheringService.getSymbolsMax() by: ['dataSource', 'symbol']
).map((symbol) => { });
return symbol;
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol
};
});
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
}) })
).map((symbolProfile) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
return {
marketDataItemCount,
activityCount: symbolProfile._count.Order,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
}; };
} }
public async getMarketDataBySymbol( public async getMarketDataBySymbol({
aSymbol: string dataSource,
): Promise<AdminMarketDataDetails> { symbol
}: {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
return { return {
marketData: await this.marketDataService.marketDataItems({ marketData: await this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
where: { where: {
symbol: aSymbol dataSource,
symbol
} }
}) })
}; };

View File

@ -1,6 +1,13 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -15,8 +22,11 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> { public async export(
return await this.exportService.export({ @Query('activityIds') activityIds?: string[]
): Promise<Export> {
return this.exportService.export({
activityIds,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

View File

@ -7,25 +7,62 @@ import { Injectable } from '@nestjs/common';
export class ExportService { export class ExportService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> { public async export({
const orders = await this.prismaService.order.findMany({ activityIds,
userId
}: {
activityIds?: string[];
userId: string;
}): Promise<Export> {
let orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true,
currency: true, currency: true,
dataSource: true, dataSource: true,
date: true, date: true,
fee: true, fee: true,
id: true,
quantity: true, quantity: true,
symbol: true, SymbolProfile: true,
type: true, type: true,
unitPrice: true unitPrice: true
}, },
where: { userId } where: { userId }
}); });
if (activityIds) {
orders = orders.filter((order) => {
return activityIds.includes(order.id);
});
}
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
orders orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}
)
}; };
} }
} }

View File

@ -1,5 +1,5 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ImportController], controllers: [ImportController],
providers: [CacheService, ImportService, OrderService] providers: [CacheService, ImportService]
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -20,6 +20,11 @@ export class ImportService {
orders: Partial<Order>[]; orders: Partial<Order>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const order of orders) {
order.dataSource =
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
}
await this.validateOrders({ orders, userId }); await this.validateOrders({ orders, userId });
for (const { for (const {
@ -34,11 +39,7 @@ export class ImportService {
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder({ await this.orderService.createOrder({
Account: { accountId,
connect: {
id_userId: { userId, id: accountId }
}
},
currency, currency,
dataSource, dataSource,
fee, fee,
@ -46,7 +47,22 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice, unitPrice,
userId,
date: parseISO(<string>(<unknown>date)), date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } } User: { connect: { id: userId } }
}); });
} }

View File

@ -1,32 +1,33 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { 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 {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS'; private static CACHE_KEY_STATISTICS = 'STATISTICS';
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@ -50,6 +51,10 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog); globalPermissions.push(permissions.enableBlog);
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource(DataSource.RAKUTEN);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport); globalPermissions.push(permissions.enableImport);
} }
@ -91,7 +96,6 @@ export class InfoService {
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions()
}; };
@ -187,9 +191,15 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: DEMO_USER_ID
}); });
} }
@ -218,19 +228,19 @@ export class InfoService {
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30); const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
statistics = { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d newUsers30d,
slackCommunityUsers
}; };
await this.redisCacheService.set( await this.redisCacheService.set(

View File

@ -1,14 +1,22 @@
import { DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator'; import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@IsOptional()
accountId: string; accountId: string;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
@IsOptional()
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()

View File

@ -0,0 +1,10 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
}
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
valueInBaseCurrency: number;
}

View File

@ -1,5 +1,7 @@
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.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';
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';
@ -14,7 +16,8 @@ import {
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -23,6 +26,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -57,55 +61,43 @@ export class OrderController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<Activities> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.orders({ let activities = await this.orderService.getOrders({
include: { userCurrency,
Account: { includeDrafts: true,
include: { userId: impersonationUserId || this.request.user.id
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
}); });
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
} }
return orders; return { activities };
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
return this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if ( if (
!hasPermission(this.request.user.permissions, permissions.createOrder) !hasPermission(this.request.user.permissions, permissions.createOrder)
@ -116,39 +108,31 @@ export class OrderController {
); );
} }
const date = parseISO(data.date);
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder({ return this.orderService.createOrder({
...data, ...data,
Account: { date: parseISO(data.date),
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
} }
}, },
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
}); });
} }
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if ( if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) !hasPermission(this.request.user.permissions, permissions.updateOrder)

View File

@ -1,9 +1,11 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,13 +18,14 @@ import { OrderService } from './order.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
UserModule UserModule
], ],
controllers: [OrderController], controllers: [OrderController],
providers: [CacheService, OrderService], providers: [AccountService, CacheService, OrderService],
exports: [OrderService] exports: [OrderService]
}) })
export class OrderModule {} export class OrderModule {}

View File

@ -1,15 +1,22 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { Activity } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -42,7 +49,24 @@ export class OrderService {
}); });
} }
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> { public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
const Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
// Convert the symbol to uppercase to avoid case-sensitive duplicates // Convert the symbol to uppercase to avoid case-sensitive duplicates
@ -65,9 +89,15 @@ export class OrderService {
await this.cacheService.flush(); await this.cacheService.flush();
delete data.accountId;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
return this.prismaService.order.create({ return this.prismaService.order.create({
data: { data: {
...data, ...orderData,
Account,
isDraft, isDraft,
symbol symbol
} }
@ -82,28 +112,65 @@ export class OrderService {
}); });
} }
public getOrders({ public async getOrders({
includeDrafts = false, includeDrafts = false,
types,
userCurrency,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string; userId: string;
}) { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; where.isDraft = false;
} }
return this.orders({ if (types) {
where, where.OR = types.map((type) => {
include: { return {
// eslint-disable-next-line @typescript-eslint/naming-convention type: {
Account: true, equals: type
// eslint-disable-next-line @typescript-eslint/naming-convention }
SymbolProfile: true };
}, });
orderBy: { date: 'asc' } }
return (
await this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
include: {
Platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
};
}); });
} }

View File

@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
); );
}); });
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({

View File

@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService
) {} ) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({ public async getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,

View File

@ -6,7 +6,7 @@ export interface CurrentPositions {
positions: TimelinePosition[]; positions: TimelinePosition[];
grossPerformance: Big; grossPerformance: Big;
grossPerformancePercentage: Big; grossPerformancePercentage: Big;
netAnnualizedPerformance: Big; netAnnualizedPerformance?: Big;
netPerformance: Big; netPerformance: Big;
netPerformancePercentage: Big; netPerformancePercentage: Big;
currentValue: Big; currentValue: Big;

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -0,0 +1,5 @@
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
itemType?: '' | 'start' | 'end';
}

View File

@ -1,3 +1,4 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
name: string; name: string;
netPerformance: number; netPerformance: number;
netPerformancePercent: number; netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

@ -0,0 +1,73 @@
import Big from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('annualized performance percentage', () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => {
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

View File

@ -0,0 +1,858 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculatorNew {
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasErrorsInSymbolMetrics = false;
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
positions,
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
};
}
public getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalUnits = new Big(0);
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate ?? new Big(0)
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate ?? new Big(0)
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
if (
!initialValue &&
order.itemType !== 'start' &&
order.itemType !== 'end'
) {
initialValue = transactionInvestment;
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestment = totalInvestment
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestment.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.sub(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.sub(fees.sub(order.fee))
.sub(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.sub(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.sub(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
return {
initialValue,
hasErrors: !initialValue || !unitPriceAtEndDate,
netPerformance: totalNetPerformance,
netPerformancePercentage: timeWeightedNetPerformancePercentage,
grossPerformance: totalGrossPerformance,
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.add(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment);
fees = fees.add(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
userCurrency: this.currency
});
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.add(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case 'BUY':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
@Injectable()
export class PortfolioServiceStrategy {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceNew: PortfolioServiceNew,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public get() {
if (
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
) {
return this.portfolioServiceNew;
}
return this.portfolioService;
}
}

View File

@ -4,18 +4,21 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.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 { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -25,17 +28,16 @@ import {
Inject, Inject,
Param, Param,
Query, Query,
Res, UseGuards,
UseGuards UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioServiceStrategy } from './portfolio-service.strategy';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -43,73 +45,31 @@ export class PortfolioController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<InvestmentItem[]> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json([]);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json(investments);
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getChart( public async getChart(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range
@Res() res: Response
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart( const historicalDataContainer = await this.portfolioServiceStrategy
impersonationId, .get()
range .getChart(impersonationId, range);
);
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
let hasNullValue = false; let hasError = false;
chartData.forEach((chartDataItem) => { chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) { if (hasNotDefinedValuesInObject(chartDataItem)) {
hasNullValue = true; hasError = true;
} }
}); });
if (hasNullValue) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
@ -130,37 +90,40 @@ export class PortfolioController {
}); });
} }
return <any>res.json({ return {
hasError,
chart: chartData, chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh, isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow isAllTimeLow: historicalDataContainer.isAllTimeLow
}); };
} }
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range
@Res() res: Response ): Promise<PortfolioDetails & { hasError: boolean }> {
): Promise<PortfolioDetails> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic' this.request.user.subscription.type === 'Basic'
) { ) {
res.status(StatusCodes.FORBIDDEN); throw new HttpException(
return <any>res.json({ accounts: {}, holdings: {} }); getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
} }
let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioServiceStrategy
impersonationId, .get()
this.request.user.id, .getDetails(impersonationId, this.request.user.id, range);
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED); hasError = true;
} }
if ( if (
@ -198,54 +161,79 @@ export class PortfolioController {
} }
} }
return <any>res.json({ accounts, holdings }); return { accounts, hasError, holdings };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments = await this.portfolioServiceStrategy
.get()
.getInvestments(impersonationId);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return { firstOrderDate: parseDate(investments[0]?.date), investments };
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPerformance( public async getPerformance(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range
@Res() res: Response ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformance> { const performanceInformation = await this.portfolioServiceStrategy
const performanceInformation = await this.portfolioService.getPerformance( .get()
impersonationId, .getPerformance(impersonationId, range);
range
);
if (performanceInformation?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
let performance = performanceInformation.performance;
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performance = nullifyValuesInObject(performance, [ performanceInformation.performance = nullifyValuesInObject(
'currentGrossPerformance', performanceInformation.performance,
'currentValue' ['currentGrossPerformance', 'currentValue']
]); );
} }
return <any>res.json(performance); return performanceInformation;
} }
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range
@Res() res: Response
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions( const result = await this.portfolioServiceStrategy
impersonationId, .get()
range .getPositions(impersonationId, range);
);
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
@ -261,13 +249,12 @@ export class PortfolioController {
}); });
} }
return <any>res.json(result); return result;
} }
@Get('public/:accessId') @Get('public/:accessId')
public async getPublic( public async getPublic(
@Param('accessId') accessId, @Param('accessId') accessId
@Res() res: Response
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({ const user = await this.userService.user({
@ -275,8 +262,10 @@ export class PortfolioController {
}); });
if (!access) { if (!access) {
res.status(StatusCodes.NOT_FOUND); throw new HttpException(
return <any>res.json({ accounts: {}, holdings: {} }); getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
} }
let hasDetails = true; let hasDetails = true;
@ -284,10 +273,9 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
const { holdings } = await this.portfolioService.getDetails( const { holdings } = await this.portfolioServiceStrategy
access.userId, .get()
access.userId .getDetails(access.userId, access.userId);
);
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
@ -320,7 +308,7 @@ export class PortfolioController {
} }
} }
return <any>res.json(portfolioPublicDetails); return portfolioPublicDetails;
} }
@Get('summary') @Get('summary')
@ -328,7 +316,9 @@ export class PortfolioController {
public async getSummary( public async getSummary(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> { ): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId); let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if ( if (
impersonationId || impersonationId ||
@ -340,6 +330,7 @@ export class PortfolioController {
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance', 'currentNetPerformance',
'currentValue', 'currentValue',
'dividend',
'fees', 'fees',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
@ -350,16 +341,17 @@ export class PortfolioController {
return summary; return summary;
} }
@Get('position/:symbol') @Get('position/:dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition( let position = await this.portfolioServiceStrategy
impersonationId, .get()
symbol .getPosition(dataSource, impersonationId, symbol);
);
if (position) { if (position) {
if ( if (
@ -370,6 +362,7 @@ export class PortfolioController {
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance', 'netPerformance',
'orders',
'quantity', 'quantity',
'value' 'value'
]); ]);
@ -387,19 +380,18 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string
@Res() res: Response
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic' this.request.user.subscription.type === 'Basic'
) { ) {
res.status(StatusCodes.FORBIDDEN); throw new HttpException(
return <any>res.json({ rules: [] }); getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
} }
return <any>( return await this.portfolioServiceStrategy.get().getReport(impersonationId);
res.json(await this.portfolioService.getReport(impersonationId))
);
} }
} }

View File

@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioService], exports: [PortfolioServiceStrategy],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioService, PortfolioService,
PortfolioServiceNew,
PortfolioServiceStrategy,
RulesService RulesService
] ]
}) })

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, sortBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -107,7 +107,7 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
value: details.accounts[account.name]?.current ?? 0 value: details.accounts[account.id]?.current ?? 0
}; };
delete result.Order; delete result.Order;
@ -150,12 +150,33 @@ export class PortfolioService {
return []; return [];
} }
return portfolioCalculator.getInvestments().map((item) => { const investments = portfolioCalculator.getInvestments().map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() investment: item.investment.toNumber()
}; };
}); });
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async getChart( public async getChart(
@ -324,6 +345,7 @@ export class PortfolioService {
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange, exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
@ -364,14 +386,21 @@ export class PortfolioService {
} }
public async getPosition( public async getPosition(
aDataSource: DataSource,
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter( const orders = (
(order) => order.symbol === aSymbol await this.orderService.getOrders({ userCurrency, userId })
); ).filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
@ -388,6 +417,7 @@ export class PortfolioService {
name: undefined, name: undefined,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
orders: [],
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined, transactionCount: undefined,
@ -400,17 +430,21 @@ export class PortfolioService {
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders
currency: order.currency, .filter((order) => {
dataSource: order.dataSource, return order.type === 'BUY' || order.type === 'SELL';
date: format(order.date, DATE_FORMAT), })
fee: new Big(order.fee), .map((order) => ({
name: order.SymbolProfile?.name, currency: order.currency,
quantity: new Big(order.quantity), dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
symbol: order.symbol, date: format(order.date, DATE_FORMAT),
type: order.type, fee: new Big(order.fee),
unitPrice: new Big(order.unitPrice) name: order.SymbolProfile?.name,
})); quantity: new Big(order.quantity),
symbol: order.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -440,19 +474,18 @@ export class PortfolioService {
} = position; } = position;
// Convert investment, gross and net performance to currency of user // Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(), position.investment?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const grossPerformance = this.exchangeRateDataService.toCurrency( const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(), position.grossPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const netPerformance = this.exchangeRateDataService.toCurrency( const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(), position.netPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
@ -521,6 +554,7 @@ export class PortfolioService {
minPrice, minPrice,
name, name,
netPerformance, netPerformance,
orders,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
@ -578,6 +612,7 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
name, name,
orders,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -655,7 +690,9 @@ export class PortfolioService {
grossPerformancePercentage: grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState, marketState:
dataProviderResponses[position.symbol]?.marketState ??
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -726,22 +763,6 @@ export class PortfolioService {
}; };
} }
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
@ -822,7 +843,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees(orders) this.getFees(orders).toNumber()
) )
], ],
{ baseCurrency: currency } { baseCurrency: currency }
@ -832,21 +853,25 @@ export class PortfolioService {
} }
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails( const { balance } = await this.accountService.getCashDetails(
userId, userId,
currency userCurrency
); );
const orders = await this.orderService.getOrders({ userId }); const orders = await this.orderService.getOrders({
const fees = this.getFees(orders); userCurrency,
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, 'BUY'); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, 'SELL'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell); const committedFunds = new Big(totalBuy).sub(totalSell);
@ -856,14 +881,19 @@ export class PortfolioService {
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
dividend,
fees, fees,
firstOrderDate, firstOrderDate,
netWorth, netWorth,
totalBuy,
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance, cash: balance,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
ordersCount: orders.length, ordersCount: orders.filter((order) => {
totalBuy: totalBuy, return order.type === 'BUY' || order.type === 'SELL';
totalSell: totalSell }).length
}; };
} }
@ -875,8 +905,8 @@ export class PortfolioService {
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big; investment: Big;
value: Big;
userCurrency: string; userCurrency: string;
value: Big;
}) { }) {
const cashPositions = {}; const cashPositions = {};
@ -936,6 +966,47 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -964,16 +1035,22 @@ export class PortfolioService {
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
}> { }> {
const orders = await this.orderService.getOrders({ includeDrafts, userId }); const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({
includeDrafts,
userCurrency,
userId,
types: ['BUY', 'SELL']
});
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };
} }
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big( fee: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -1026,10 +1103,11 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
); );
accounts[account.name] = { accounts[account.id] = {
balance: convertedBalance, balance: convertedBalance,
currency: account.currency, currency: account.currency,
current: convertedBalance, current: convertedBalance,
name: account.name,
original: convertedBalance original: convertedBalance
}; };
@ -1043,16 +1121,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) { if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current += accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol; currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original += accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol; originalValueOfSymbol;
} else { } else {
accounts[order.Account?.name || UNKNOWN_KEY] = { accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
} }

View File

@ -7,6 +7,7 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpException, HttpException,
Inject, Inject,
Logger, Logger,
@ -17,7 +18,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -32,11 +32,9 @@ export class SubscriptionController {
) {} ) {}
@Post('redeem-coupon') @Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async redeemCoupon( public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
if (!this.request.user) { if (!this.request.user) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -74,12 +72,10 @@ export class SubscriptionController {
`Subscription for user '${this.request.user.id}' has been created with coupon` `Subscription for user '${this.request.user.id}' has been created with coupon`
); );
res.status(StatusCodes.OK); return {
return <any>res.json({
message: getReasonPhrase(StatusCodes.OK), message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK statusCode: StatusCodes.OK
}); };
} }
@Get('stripe/callback') @Get('stripe/callback')

View File

@ -1,19 +1,17 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -23,22 +21,19 @@ import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')
export class SymbolController { export class SymbolController {
public constructor( public constructor(private readonly symbolService: SymbolService) {}
private readonly symbolService: SymbolService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/** /**
* Must be before /:symbol * Must be before /:symbol
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query() { query = '' } @Query() { query = '' }
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
const encodedQuery = encodeURIComponent(query.toLowerCase()); return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -52,11 +47,12 @@ export class SymbolController {
*/ */
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe) @Query('includeHistoricalData') includeHistoricalData?: number
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(

View File

@ -5,10 +5,9 @@ import {
IDataProviderHistoricalResponse IDataProviderHistoricalResponse
} 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
@ -18,35 +17,34 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService
private readonly prismaService: PrismaService
) {} ) {}
public async get({ public async get({
dataGatheringItem, dataGatheringItem,
includeHistoricalData = false includeHistoricalData
}: { }: {
dataGatheringItem: IDataGatheringItem; dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean; includeHistoricalData?: number;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[]; let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData) { if (includeHistoricalData > 0) {
const days = 10; const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol] symbols: [dataGatheringItem.symbol]
}); });
historicalData = marketData.map(({ date, marketPrice }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {
return { return {
date: date.toISOString(), value,
value: marketPrice date: date.toISOString()
}; };
}); });
} }
@ -93,32 +91,6 @@ export class SymbolService {
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { items } = await this.dataProviderService.search(aQuery);
results.items = items; results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);

View File

@ -1,4 +1,7 @@
import { Role } from '@prisma/client';
export interface UserItem { export interface UserItem {
accessToken?: string; accessToken?: string;
authToken: string; authToken: string;
role: Role;
} }

View File

@ -1,6 +1,11 @@
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsBoolean() @IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
} }

View File

@ -83,12 +83,15 @@ export class UserController {
} }
} }
const { accessToken, id } = await this.userService.createUser({ const hasAdmin = await this.userService.hasAdmin();
provider: Provider.ANONYMOUS
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
}); });
return { return {
accessToken, accessToken,
role,
authToken: this.jwtService.sign({ authToken: this.jwtService.sign({
id id
}) })
@ -115,6 +118,12 @@ export class UserController {
...data ...data
}; };
for (const key in userSettings) {
if (userSettings[key] === false) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({ return await this.userService.updateUserSetting({
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id

View File

@ -70,6 +70,18 @@ export class UserService {
}; };
} }
public async hasAdmin() {
const usersWithAdminRole = await this.users({
where: {
role: {
equals: 'ADMIN'
}
}
});
return usersWithAdminRole.length > 0;
}
public isRestrictedView(aUser: UserWithSettings) { public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false; return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
} }
@ -168,7 +180,11 @@ export class UserService {
return hash.digest('hex'); return hash.digest('hex');
} }
public async createUser(data?: Prisma.UserCreateInput): Promise<User> { public async createUser(data: Prisma.UserCreateInput): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
let user = await this.prismaService.user.create({ let user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
@ -187,7 +203,7 @@ export class UserService {
} }
}); });
if (data.provider === Provider.ANONYMOUS) { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken( const accessToken = this.createAccessToken(
user.id, user.id,
this.getRandomString(10) this.getRandomString(10)

View File

@ -0,0 +1,39 @@
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
if (request.params.dataSource) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
}
}
return next.handle();
}
}

View File

@ -0,0 +1,73 @@
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
}
return data;
})
);
}
}

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].current investment: account.current
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(accounts).forEach((account) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = account; maxItem = account;
} }
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = account; maxItem = account;
} }
}); }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
} }
public evaluate(ruleSettings?: Settings) { public evaluate(ruleSettings?: Settings) {
const platforms: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
platforms[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].original investment: account.original
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(platforms).forEach((platform) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = platform; maxItem = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += platform.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (platform.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = platform; maxItem = account;
} }
}); }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -13,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
@ -25,6 +26,9 @@ export class ConfigurationService {
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
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_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),

View File

@ -1,8 +1,11 @@
{ {
"1INCH": "1inch", "1INCH": "1inch",
"ALGO": "Algorand", "ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon", "MATIC": "Polygon",
"MINA": "Mina Protocol",
"SHIB": "Shiba Inu", "SHIB": "Shiba Inu",
"SOL": "Solana", "SOL": "Solana",
"UNI3": "Uniswap" "UNI3": "Uniswap"

View File

@ -1,12 +1,11 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
PROPERTY_LAST_DATA_GATHERING, PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING, PROPERTY_LOCKED_DATA_GATHERING
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -17,7 +16,6 @@ import {
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -29,7 +27,6 @@ export class DataGatheringService {
private dataGatheringProgress: number; private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -245,7 +242,7 @@ export class DataGatheringService {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ currentData[symbol] = await dataEnhancer.enhance({
response, response,
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
@ -337,16 +334,25 @@ export class DataGatheringService {
?.marketPrice; ?.marketPrice;
} }
try { if (lastMarketPrice) {
await this.prismaService.marketData.create({ try {
data: { await this.prismaService.marketData.create({
dataSource, data: {
symbol, dataSource,
date: currentDate, symbol,
marketPrice: lastMarketPrice date: currentDate,
} marketPrice: lastMarketPrice
}); }
} catch {} });
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format(
currentDate,
DATE_FORMAT
)}.`
);
}
// Count month one up for iteration // Count month one up for iteration
currentDate = new Date( currentDate = new Date(
@ -448,11 +454,7 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
public async reset() { public async reset() {
@ -468,41 +470,52 @@ export class DataGatheringService {
}); });
} }
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather: IDataGatheringItem[] = [];
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: ghostfolioFearAndGreedIndexSymbol
});
}
return benchmarksToGather;
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const symbolProfilesToGather = ( const symbolProfiles = await this.prismaService.symbolProfile.findMany({
await this.prismaService.symbolProfile.findMany({ orderBy: [{ symbol: 'asc' }],
orderBy: [{ symbol: 'asc' }], select: {
select: { dataSource: true,
dataSource: true, scraperConfiguration: true,
scraperConfiguration: true, symbol: true
symbol: true }
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsNotToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
where: {
date: { gt: startDate }
} }
}) })
).map((symbolProfile) => { )
return { .filter((group) => {
...symbolProfile, return group._count >= 6;
date: startDate })
}; .map((group) => {
}); return group.symbol;
});
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
})
.map((symbolProfile) => {
return {
...symbolProfile,
date: startDate
};
});
const currencyPairsToGather = this.exchangeRateDataService const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
@ -511,30 +524,22 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prismaService.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true }
}); });
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter( return distinctOrders.filter((distinctOrder) => {
(distinctOrder) => { return (
return ( distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.GHOSTFOLIO && distinctOrder.dataSource !== DataSource.RAKUTEN
distinctOrder.dataSource !== DataSource.RAKUTEN );
); });
}
);
} }
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aQuery);
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
dataSource: DataSource.ALPHA_VANTAGE, dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']
}; };

View File

@ -7,6 +7,9 @@ const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings'; private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries TrackinsightDataEnhancerService.countries
)) { )) {
if (country.name === name) { if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = key; countryCode = key;
break; break;
} }

View File

@ -1,6 +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 { 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 { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService, YahooFinanceService,
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService
], ],
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
] ]

View File

@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { groupBy, isEmpty } from 'lodash';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -30,18 +30,27 @@ export class DataProviderService {
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
} = {}; } = {};
for (const item of items) { const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const promises = []; const promises = [];
for (const symbol of Object.keys(response)) {
const promise = Promise.resolve(response[symbol]); for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push( promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse)) promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
); );
} }
@ -149,13 +158,13 @@ export class DataProviderService {
return result; return result;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push( promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol) this.getDataProvider(DataSource[dataSource]).search(aQuery)
); );
} }
@ -176,7 +185,7 @@ export class DataProviderService {
} }
public getPrimaryDataSource(): DataSource { public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]]; return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
} }
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} 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';
import { import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: symbolProfile?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: this.getName(),
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
} }
private extractNumberFromString(aString: string): number { private extractNumberFromString(aString: string): number {

View File

@ -0,0 +1,181 @@
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,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable()
export class GoogleSheetsService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private async getSheet({
sheetId,
symbol
}: {
sheetId: string;
symbol: string;
}) {
const doc = new GoogleSpreadsheet(sheetId);
await doc.useServiceAccountAuth({
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
private_key: this.configurationService
.get('GOOGLE_SHEETS_PRIVATE_KEY')
.replace(/\\n/g, '\n')
});
await doc.loadInfo();
const sheet = doc.sheetsByTitle[symbol];
await sheet.loadCells();
return sheet;
}
}

View File

@ -1,11 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '../../interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
@ -23,5 +22,5 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>; search(aQuery: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass, assetClass,
assetSubClass, assetSubClass,
currency: value.price?.currency, currency: value.price?.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || value.price?.marketState === 'REGULAR' ||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`, `${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET', 'GET',
'json', 'json',
200 200
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: value.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
name: value.name name: value.name
}); });
} }

View File

@ -58,9 +58,9 @@ export class ExchangeRateDataService {
getYesterday() getYesterday()
); );
if (isEmpty(result)) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not yet available // if historical data is not fully available
const historicalData = await this.dataProviderService.get( const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -157,7 +157,12 @@ export class ExchangeRateDataService {
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((account) => { ).forEach((account) => {
currencies.push(account.currency); currencies.push(account.currency);
@ -167,7 +172,12 @@ export class ExchangeRateDataService {
await this.prismaService.settings.findMany({ await this.prismaService.settings.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((userSettings) => { ).forEach((userSettings) => {
currencies.push(userSettings.currency); currencies.push(userSettings.currency);
@ -177,7 +187,12 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((symbolProfile) => { ).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
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?
ENABLE_FEATURE_BLOG: boolean; ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number; MAX_ORDERS_TO_IMPORT: number;

View File

@ -1,13 +1,29 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.marketData.deleteMany({
where: {
dataSource,
symbol
}
});
}
public async get({ public async get({
date, date,
symbol symbol
@ -67,14 +83,20 @@ export class MarketDataService {
} }
public async updateMarketData(params: { public async updateMarketData(params: {
data: Prisma.MarketDataUpdateInput; data: { dataSource: DataSource } & UpdateMarketDataDto;
where: Prisma.MarketDataWhereUniqueInput; where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> { }): Promise<MarketData> {
const { data, where } = params; const { data, where } = params;
return this.prismaService.marketData.update({ return this.prismaService.marketData.upsert({
data, where,
where create: {
dataSource: data.dataSource,
date: where.date_symbol.date,
marketPrice: data.marketPrice,
symbol: where.date_symbol.symbol
},
update: { marketPrice: data.marketPrice }
}); });
} }
} }

View File

@ -4,14 +4,26 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } 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'; import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async getSymbolProfiles( public async getSymbolProfiles(
symbols: string[] symbols: string[]

View File

@ -9,6 +9,13 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, },
{
path: 'about/changelog',
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -33,6 +40,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
{
path: 'blog',
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{ {
path: 'de/blog/2021/07/hallo-ghostfolio', path: 'de/blog/2021/07/hallo-ghostfolio',
loadChildren: () => loadChildren: () =>
@ -47,6 +59,13 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule) ).then((m) => m.HelloGhostfolioPageModule)
}, },
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
@ -66,6 +85,13 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
{
path: 'portfolio/activities',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'portfolio/allocations', path: 'portfolio/allocations',
loadChildren: () => loadChildren: () =>
@ -87,13 +113,6 @@ const routes: Routes = [
(m) => m.ReportPageModule (m) => m.ReportPageModule
) )
}, },
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'pricing', path: 'pricing',
loadChildren: () => loadChildren: () =>

View File

@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.userService.remove(); this.userService.remove();
this.router.navigate(['/']); document.location.href = '/';
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -15,7 +15,7 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">

View File

@ -46,9 +46,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'account', 'account',
'currency',
'platform', 'platform',
'transactions', 'transactions',
'currency',
'balance', 'balance',
'value' 'value'
]; ];

View File

@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { AccountsTableComponent } from './accounts-table.component'; import { AccountsTableComponent } from './accounts-table.component';
@NgModule({ @NgModule({

View File

@ -20,6 +20,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketData: AdminMarketDataItem[] = []; public marketData: AdminMarketDataItem[] = [];
@ -43,6 +44,32 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
} }
public onDeleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ public onGatherSymbol({
dataSource, dataSource,
symbol symbol
@ -56,22 +83,33 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public setCurrentSymbol(aSymbol: string) {
this.marketDataDetails = [];
if (this.currentSymbol === aSymbol) {
this.currentSymbol = '';
} else {
this.currentSymbol = aSymbol;
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol(this.currentSymbol); this.fetchAdminMarketDataBySymbol({
dataSource: this.currentDataSource,
symbol: this.currentSymbol
});
}
}
public setCurrentProfile({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
this.currentDataSource = undefined;
this.currentSymbol = '';
} else {
this.currentDataSource = dataSource;
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
} }
} }
@ -91,9 +129,15 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}); });
} }
private fetchAdminMarketDataBySymbol(aSymbol: string) { private fetchAdminMarketDataBySymbol({
this.dataService dataSource,
.fetchAdminMarketDataBySymbol(aSymbol) symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => { .subscribe(({ marketData }) => {
this.marketDataDetails = marketData; this.marketDataDetails = marketData;

View File

@ -6,7 +6,9 @@
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> <th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> <th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th> <th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
@ -14,13 +16,15 @@
<ng-container *ngFor="let item of marketData; let i = index"> <ng-container *ngFor="let item of marketData; let i = index">
<tr <tr
class="cursor-pointer mat-row" class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)" (click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
> >
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td> <td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td> <td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }} {{ (item.date | date: defaultDateFormat) ?? '' }}
</td> </td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
@ -38,11 +42,26 @@
> >
Gather Data Gather Data
</button> </button>
<button
i18n
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Profile Data
</button>
<button
i18n
mat-menu-item
[disabled]="item.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
>
Delete Profile Data
</button>
</mat-menu> </mat-menu>
</td> </td>
</tr> </tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row"> <tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="4"> <td class="p-1" colspan="6">
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="item.dataSource" [dataSource]="item.dataSource"
[marketData]="marketDataDetails" [marketData]="marketDataDetails"

View File

@ -1,5 +1,6 @@
<button <button
*ngIf="deviceType === 'mobile'" *ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >

View File

@ -1,4 +1,7 @@
:host { :host {
display: flex; display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0; min-height: 0;
padding: 0;
} }

View File

@ -1,13 +1,19 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
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 { import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -19,7 +25,9 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public positions: Position[];
public user: User; public user: User;
@ -33,9 +41,28 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -58,18 +85,69 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dateRange = this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max'; <DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
this.update(); this.update();
} }
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() { private update() {
this.positions = undefined;
this.dataService this.dataService
.fetchPositions({ range: this.dateRange }) .fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,4 +1,12 @@
<div class="container justify-content-center pb-3 px-3"> <div class="container justify-content-center p-3">
<div class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="row"> <div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> <div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card class="p-0"> <mat-card class="p-0">
@ -6,6 +14,7 @@
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
[range]="dateRange" [range]="dateRange"
@ -17,8 +26,8 @@
class="mt-3" class="mt-3"
i18n i18n
mat-button mat-button
[routerLink]="['/portfolio', 'transactions']" [routerLink]="['/portfolio', 'activities']"
>Manage Transactions...</a >Manage Activities...</a
> >
</div> </div>
</div> </div>

View File

@ -3,7 +3,9 @@ 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 { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule, GfPositionsModule,
GfToggleModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
RouterModule RouterModule

View File

@ -4,9 +4,8 @@ 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 { User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -19,7 +18,9 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
public info: InfoItem;
public isLoading = true; public isLoading = true;
public readonly numberOfDays = 90;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -32,6 +33,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo();
this.isLoading = true; this.isLoading = true;
this.userService.stateChanged this.userService.stateChanged
@ -48,8 +50,8 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) { if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: DataSource.RAKUTEN, dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: true, includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,18 +1,10 @@
<div <div
class=" class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
> >
<div class="no-gutters row w-100"> <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 10 Days</small> <small i18n>Last {{ numberOfDays }} Days</small>
</div> </div>
<gf-line-chart <gf-line-chart
class="mb-5" class="mb-5"

View File

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
@ -7,7 +6,9 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces'; import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } 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 { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -21,15 +22,11 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions: ToggleOption[] = [ public dateRangeOptions = defaultDateRangeOptions;
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
public deviceType: string; public deviceType: string;
public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean; public isAllTimeHigh: boolean;
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
@ -56,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -116,7 +118,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ range: this.dateRange }) .fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.performance = response; this.hasError = response.hasErrors;
this.performance = response.performance;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -1,15 +1,5 @@
<div <div
class=" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
align-items-center
container
d-flex
flex-column
h-100
justify-content-center
overview
p-0
position-relative
"
> >
<div class="row w-100"> <div class="row w-100">
<div class="chart-container col"> <div class="chart-container col">
@ -23,7 +13,7 @@
[showYAxis]="false" [showYAxis]="false"
></gf-line-chart> ></gf-line-chart>
<div <div
*ngIf="historicalDataItems?.length === 0" *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100" class="align-items-center d-flex h-100 justify-content-center w-100"
> >
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
@ -37,6 +27,8 @@
<gf-portfolio-performance <gf-portfolio-performance
class="pb-4" class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasError]="hasError"
[isAllTimeHigh]="isAllTimeHigh" [isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow" [isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"

View File

@ -10,6 +10,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import {
Chart, Chart,
@ -19,7 +20,7 @@ import {
PointElement, PointElement,
TimeScale TimeScale
} from 'chart.js'; } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
templateUrl: './investment-chart.component.html', templateUrl: './investment-chart.component.html',
styleUrls: ['./investment-chart.component.scss'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() daysInMarket: number;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
); );
} }
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
if (this.investments) { if (this.investments) {
this.initialize(); this.initialize();
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
if (this.investments?.length > 0) { if (this.investments?.length > 0) {
// Extend chart by three months (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.investments[0];
this.investments.unshift({ this.investments.unshift({
...firstItem, ...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(), date: subDays(
parseISO(firstItem.date),
this.daysInMarket * 0.05 || 90
).toISOString(),
investment: 0 investment: 0
}); });
// Extend chart by three months (after) // Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1]; const lastItem = this.investments[this.investments.length - 1];
this.investments.push({ this.investments.push({
...lastItem, ...lastItem,
date: addMonths(new Date(), 3).toISOString() date: addDays(
parseDate(lastItem.date),
this.daysInMarket * 0.05 || 90
).toISOString()
}); });
} }
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
}, },
y: { y: {
display: false, display: !this.isInPercent,
grid: { grid: {
display: false display: false
}, },
ticks: { ticks: {
display: false display: true,
callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
},
mirror: true,
z: 1
} }
} }
} }

View File

@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
@NgModule({ @NgModule({
declarations: [InvestmentChartComponent], declarations: [InvestmentChartComponent],
exports: [InvestmentChartComponent], exports: [InvestmentChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule]
providers: []
}) })
export class GfInvestmentChartModule {} export class GfInvestmentChartModule {}

View File

@ -1,6 +0,0 @@
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
export interface PositionDetailDialogParams {
deviceType: string;
historicalDataItems: LineChartItem[];
}

View File

@ -1,12 +0,0 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16 / 9;
margin: 0 -1rem;
}
}
}

View File

@ -1,94 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@Component({
selector: 'gf-performance-chart-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'performance-chart-dialog.html',
styleUrls: ['./performance-chart-dialog.component.scss']
})
export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[];
public benchmarkSymbol = 'VOO';
public currency: string;
public firstBuyDate: string;
public marketPrice: number;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = [];
this.marketPrice = marketPrice;
let coefficient = 1;
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
if (benchmarkItem) {
if (coefficient === 1) {
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
}
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: benchmarkItem.value * coefficient
});
} else if (
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
) {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: marketPrice * coefficient
});
} else {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: undefined
});
}
});
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,27 +0,0 @@
<gf-dialog-header
mat-dialog-title
title="Performance"
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content>
<div class="container p-0">
<gf-line-chart
class="mb-4"
symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems"
[showGradient]="true"
[showLegend]="true"
[showXAxis]="true"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -1,28 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({
declarations: [PerformanceChartDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: []
})
export class GfPerformanceChartDialogModule {}

View File

@ -1,12 +1,18 @@
<div class="container p-0"> <div class="container p-0">
<div <div class="no-gutters row">
class="no-gutters row" <div
[ngClass]="{ class="flex-grow-1 status text-muted text-right"
'text-danger': isAllTimeLow, [title]="
'text-success': isAllTimeHigh hasError && !isLoading
}" ? 'Sorry! Our data provider partner is experiencing the hiccups.'
> : ''
<div class="flex-grow-1"></div> "
>
<ion-icon
*ngIf="hasError && !isLoading"
name="alert-circle-outline"
></ion-icon>
</div>
<div *ngIf="isLoading" class="align-items-center d-flex"> <div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
@ -20,6 +26,10 @@
<div <div
class="display-4 font-weight-bold m-0 text-center value-container" class="display-4 font-weight-bold m-0 text-center value-container"
[hidden]="isLoading" [hidden]="isLoading"
[ngClass]="{
'text-danger': isAllTimeLow,
'text-success': isAllTimeHigh
}"
> >
<span #value id="value"></span> <span #value id="value"></span>
</div> </div>

View File

@ -1,6 +1,10 @@
:host { :host {
display: block; display: block;
.status {
font-size: 1.33rem;
}
.value-container { .value-container {
#value { #value {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
}) })
export class PortfolioPerformanceComponent implements OnChanges, OnInit { export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasError: boolean;
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean; @Input() isAllTimeLow: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.unit = this.baseCurrency; this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2, decimalPlaces:
this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000
? 0
: 2,
duration: 1, duration: 1,
separator: `'` separator: `'`
}).start(); }).start();

View File

@ -169,4 +169,18 @@
></gf-value> ></gf-value>
</div> </div>
</div> </div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Dividend</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.dividend"
></gf-value>
</div>
</div>
</div> </div>

View File

@ -1,6 +1,10 @@
import { DataSource } from '@prisma/client';
export interface PositionDetailDialogParams { export interface PositionDetailDialogParams {
baseCurrency: string; baseCurrency: string;
dataSource: DataSource;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
locale: string; locale: string;
symbol: string; symbol: string;
} }

View File

@ -3,11 +3,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy,
OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
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 { AssetSubClass } from '@prisma/client'; import { AssetSubClass } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html', templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy { export class PositionDetailDialog implements OnDestroy, OnInit {
public assetSubClass: AssetSubClass; public assetSubClass: AssetSubClass;
public averagePrice: number; public averagePrice: number;
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
public name: string; public name: string;
public netPerformance: number; public netPerformance: number;
public netPerformancePercent: number; public netPerformancePercent: number;
public orders: OrderWithAccount[];
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public symbol: string; public symbol: string;
@ -52,9 +55,14 @@ export class PositionDetailDialog implements OnDestroy {
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) { ) {}
public ngOnInit(): void {
this.dataService this.dataService
.fetchPositionDetail(data.symbol) .fetchPositionDetail({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
@ -72,6 +80,7 @@ export class PositionDetailDialog implements OnDestroy {
name, name,
netPerformance, netPerformance,
netPerformancePercent, netPerformancePercent,
orders,
quantity, quantity,
symbol, symbol,
transactionCount, transactionCount,
@ -104,6 +113,7 @@ export class PositionDetailDialog implements OnDestroy {
this.name = name; this.name = name;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.orders = orders;
this.quantity = quantity; this.quantity = quantity;
this.symbol = symbol; this.symbol = symbol;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
@ -175,6 +185,26 @@ export class PositionDetailDialog implements OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${this.symbol}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -124,6 +124,22 @@
</div> </div>
</div> </div>
</div> </div>
<gf-activities-table
*ngIf="orders?.length > 0"
[activities]="orders"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div> </div>
<gf-dialog-footer <gf-dialog-footer

View File

@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { PositionDetailDialog } from './position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({ @NgModule({
@ -15,6 +16,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfLineChartModule, GfLineChartModule,

View File

@ -3,7 +3,11 @@
<a <a
class="d-flex p-3 w-100" class="d-flex p-3 w-100"
[routerLink]="[]" [routerLink]="[]"
[queryParams]="{ positionDetailDialog: true, symbol: position?.symbol }" [queryParams]="{
dataSource: position?.dataSource,
positionDetailDialog: true,
symbol: position?.symbol
}"
> >
<div class="d-flex mr-2"> <div class="d-flex mr-2">
<gf-trend-indicator <gf-trend-indicator

View File

@ -5,14 +5,9 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@Component({ @Component({
selector: 'gf-position', selector: 'gf-position',
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor() {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['symbol'] === this.position?.symbol
) {
this.openDialog();
}
});
}
public ngOnInit() {} public ngOnInit() {}
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openDialog(): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
} }

View File

@ -108,7 +108,7 @@
}" }"
(click)=" (click)="
!ignoreAssetSubClasses.includes(row.assetSubClass) && !ignoreAssetSubClasses.includes(row.assetSubClass) &&
onOpenPositionDialog({ symbol: row.symbol }) onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
" "
></tr> ></tr>
</table> </table>
@ -123,7 +123,12 @@
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center"> <div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -9,17 +9,13 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client'; import { AssetClass, DataSource, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
@Component({ @Component({
selector: 'gf-positions-table', selector: 'gf-positions-table',
@ -30,6 +26,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: PortfolioPosition[]; @Input() positions: PortfolioPosition[];
@ -49,21 +46,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(private router: Router) {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol']
});
}
});
}
public ngOnInit() {} public ngOnInit() {}
@ -92,9 +75,15 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase(); this.dataSource.filter = filterValue.trim().toLowerCase();
}*/ }*/
public onOpenPositionDialog({ symbol }: { symbol: string }): void { public onOpenPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol } queryParams: { dataSource, symbol, positionDetailDialog: true }
}); });
} }
@ -106,27 +95,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { PositionsTableComponent } from './positions-table.component'; import { PositionsTableComponent } from './positions-table.component';

View File

@ -23,7 +23,10 @@
[range]="range" [range]="range"
></gf-position> ></gf-position>
</ng-container> </ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center"> <div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
export class PositionsComponent implements OnChanges, OnInit { export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: Position[]; @Input() positions: Position[];
@Input() range: string; @Input() range: string;

View File

@ -1,7 +1,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center"> <mat-card
*ngIf="hasPermissionToCreateOrder && rules === null"
class="my-2 text-center"
>
<gf-no-transactions-info-indicator <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

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

View File

@ -8,8 +8,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { ToggleOption } from '@ghostfolio/common/types';
import { ToggleOption } from './interfaces/toggle-option.type';
@Component({ @Component({
selector: 'gf-toggle', selector: 'gf-toggle',

View File

@ -1,285 +0,0 @@
<mat-form-field appearance="outline" class="w-100">
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
<mat-chip-list #chipList aria-label="Search keywords">
<mat-chip
*ngFor="let searchKeyword of searchKeywords"
class="mx-1 my-0 px-2 py-0"
matChipRemove
[removable]="true"
(removed)="removeKeyword(searchKeyword)"
>
{{ searchKeyword | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
#searchInput
name="close-outline"
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[placeholder]="placeholder"
(matChipInputTokenEnd)="addKeyword($event)"
/>
</mat-chip-list>
<mat-autocomplete
#autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)"
>
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter | gfSymbol }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<table
class="gf-table w-100"
matSort
matSortActive="date"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="count">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
></th>
<td
*matCellDef="let element; let i = index"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
{{ dataSource.data.length - i }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Date
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Type
</th>
<td mat-cell *matCellDef="let element" class="px-1">
<div
class="d-inline-flex p-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
>
<ion-icon
[name]="
element.type === 'BUY'
? 'arrow-forward-circle-outline'
: 'arrow-back-circle-outline'
"
></ion-icon>
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }}
<span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n
>Draft</span
>
</div>
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
mat-sort-header
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Quantity
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.quantity"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="unitPrice">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Unit Price
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.unitPrice"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="fee">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Fee
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.fee"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Account?.Platform?.url"
class="mr-1"
[tooltip]="element.Account?.Platform?.name"
[url]="element.Account?.Platform?.url"
></gf-symbol-icon>
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionsMenu="matMenu" xPosition="before">
<button
*ngIf="hasPermissionToImportOrders"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import</span>
</button>
<button
class="align-items-center d-flex"
mat-menu-item
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
Edit
</button>
<button i18n mat-menu-item (click)="onCloneTransaction(element)">
Clone
</button>
<button i18n mat-menu-item (click)="onDeleteTransaction(element.id)">
Delete
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="
!row.isDraft &&
onOpenPositionDialog({
symbol: row.symbol
})
"
[ngClass]="{ 'is-draft': row.isDraft }"
></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>

View File

@ -1,65 +0,0 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
.mat-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
.mat-row {
&:not(.is-draft) {
cursor: pointer;
}
.type-badge {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
ion-icon {
font-size: 1rem;
}
&.buy {
color: var(--green);
}
&.sell {
color: var(--orange);
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
.mat-table {
.type-badge {
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
}
}
}

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