Compare commits

..

108 Commits

Author SHA1 Message Date
127abb8f4e Release 1.93.0 (#566) 2021-12-21 20:24:24 +01:00
ed1136999a Feature/extend documentation for self hosting (#565)
* Extend documentation for self-hosting

* Add tag "latest" to docker image

* Update changelog
2021-12-21 20:22:58 +01:00
9f545e3e2b Feature/add cryptocurrency solana (#563)
* Add support for Solana and clean up symbol conversion (SOL1USD)

* Update changelog
2021-12-20 21:24:58 +01:00
1602f976f0 Feature/convert errors to warnings in portfolio calculator (#562)
* Convert errors to warnings

* Update changelog
2021-12-20 21:03:12 +01:00
4bf4c1a8a3 Release 1.92.0 (#561) 2021-12-19 16:57:22 +01:00
e78755c280 Replace OrderType with Type (prisma) (#560) 2021-12-19 16:52:35 +01:00
7772684413 Bugfix/fix permission for system status page (#559)
* Fix permission

* Update changelog
2021-12-19 15:55:03 +01:00
955302666e Bugfix/improve redirection on logout (#558)
* Improve redirection on logout

* Update changelog
2021-12-19 13:45:28 +01:00
ddce8cc7f9 Feature/support update of historical data (#557)
* Support update of historical data

* Update changelog
2021-12-19 10:57:50 +01:00
aca0d77e91 Feature/add line chart to historical data view (#555)
* Add line chart

* Update changelog
2021-12-18 16:43:34 +01:00
8b9379f5ce Release 1.91.0 (#553) 2021-12-18 11:41:13 +01:00
0806d0dc92 Bugfix/fix hidden statistics values (#552)
* Fix hidden values (0)

* Update changelog
2021-12-18 11:13:32 +01:00
e518bc3779 Feature/clean up all time high and low from performance endpoint (#551)
* Remove isAllTimeHigh / isAllTimeLow

* Update changelog
2021-12-18 09:20:42 +01:00
eff807dd9a Add tests for yahoo Finance symbol conversion (#550)
* Add tests for yahoo Finance symbol conversion

* Refactoring
2021-12-18 09:04:52 +01:00
155bf67f60 Bugfix/fix symbol conversion from yahoo finance with hyphen (#549)
* Fix symbol conversion from Yahoo Finance

* Update changelog
2021-12-16 18:44:08 +01:00
9aefe3747e Feature/migrate database schema to prisma 3 (#546)
* Add migration after upgrade to prisma 3

* Update changelog
2021-12-15 21:56:29 +01:00
0878febded build(prisma): fix populating database when attributes have null value (#545) 2021-12-15 21:04:26 +01:00
f953c6ea64 Release/1.90.0 (#544)
* Update stage labels

* Release 1.90.0
2021-12-14 20:04:17 +01:00
76dbf78279 implement docker build on tags (#542)
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-12-14 19:45:55 +01:00
f12866b9ec Update changelog (#543) 2021-12-13 22:31:49 +01:00
83ba5f3d9f Feature/add intro image for dark mode (#540)
* Add intro image for dark mode

* Update changelog
2021-12-13 22:26:16 +01:00
7439c1bf54 fix(custom-cryptocurrencies): add uniswap (uni3) (#541) 2021-12-13 22:25:09 +01:00
e255b76053 Feature/check currency in import validation (#538)
* Check currency in import validation (avoid importing transactions in wrong currency)

* Update changelog
2021-12-13 19:45:23 +01:00
ed7209fb53 Feature/remove transactions import limit (#536)
* Remove default transactions import limit

* Update changelog
2021-12-11 21:44:54 +01:00
008a2ab123 Support arm64 (#465)
* Support arm64

* Move prisma from devDependencies to dependencies (Fix "/bin/sh: prisma: not found" in docker build)

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-12-11 20:58:09 +01:00
2aedd74480 Release 1.89.0 (#534) 2021-12-11 17:23:29 +01:00
11076592d1 Do not log coupon code (#533) 2021-12-11 17:12:24 +01:00
ebee851b23 Feature/support data gathering by symbol and date (#532)
* Support data gathering by symbol and date

* Update changelog
2021-12-11 11:45:13 +01:00
7d3f1832b4 Feature/upgrade nx and storybook dependencies (#531)
* Upgrade dependencies
  * nx
  * storybook

* Update changelog
2021-12-10 19:50:46 +01:00
39e6abfc8c Clean up preview features (#530) 2021-12-10 19:43:53 +01:00
78e0fdb0ca Add coupon system (#529)
* Add coupon system

* Update changelog
2021-12-09 21:14:44 +01:00
606350b2ff Release 1.87.0 (#526) 2021-12-07 20:34:52 +01:00
d09cad4e05 Feature/read only mode (#520)
* Setup read only mode and update permissions dynamically

* Update changelog
2021-12-07 20:24:15 +01:00
069660afe4 Feature/increase fear and greed index to 10 days (#525)
* Increase to 10 days

* Update changelog
2021-12-07 19:10:40 +01:00
4d9a223491 Feature/add system message (#519)
* Add system message

* Update changelog
2021-12-06 20:51:38 +01:00
aed8f5cf04 Feature/upgrade prisma to version 3.6.0 (#518)
* Upgrade prisma from version 2.30.2 to 3.6.0

* Update changelog
2021-12-05 16:52:24 +01:00
1beb4de62f Feature/support additional currencies (#517)
* Support additional currencies

* Update changelog
2021-12-04 21:05:11 +01:00
9bc3505ded Release 1.86.0 (#516) 2021-12-04 11:51:06 +01:00
3e82de6b21 Feature/add historical data chart of fear and greed index (#515)
* Add historical data chart of market mood

* Update changelog
2021-12-04 11:49:00 +01:00
563f354e7e Feature/symbol to uppercase to avoid duplicates (#514)
* Convert the symbol to uppercase to avoid case-sensitive duplicates

* Update changelog
2021-12-04 11:40:12 +01:00
e96e6c717c Feature/enable import by default (#513)
* Enable import by default

* Update changelog
2021-12-04 08:57:22 +01:00
961774ce9f Feature/improve market data detail (#511)
* Improve historical data view (hide invalid and future dates)

* Update changelog
2021-12-03 21:24:05 +01:00
49f46e1a1e Bugfix/improve allocations by currency with cash balances (#508)
* Improve allocations by currency in combination with cash balances

* Update changelog
2021-12-02 21:53:38 +01:00
050c0a4da7 Release 1.85.0 (#506) 2021-12-01 21:20:33 +01:00
4908e6d35d Bugfix/fix data gathering of fear and greed index (#505)
* Fix data gathering of fear and greed index

* Update changelog
2021-12-01 21:18:46 +01:00
fe4013830d Release 1.84.0 (#504) 2021-11-30 21:07:17 +01:00
11be6f630f Feature/expose data gathering by symbol (#503)
* Expose data gathering by symbol as endpoint

* Update changelog
2021-11-30 21:06:10 +01:00
85d123e1b1 Fix colspan (#502) 2021-11-29 21:39:54 +01:00
c5e9804c25 Release 1.83.0 (#501) 2021-11-29 21:17:20 +01:00
1f042ee791 Feature/eliminate redundant storage of historical exchange rates (#500)
* Eliminate redundant storage of historical exchange rates

* Clean up experimental API

* Update changelog
2021-11-29 21:08:58 +01:00
da6eaa0d77 Harmonize error log (#499) 2021-11-29 21:01:53 +01:00
3f31cec859 Release 1.82.0 (#498) 2021-11-28 19:58:00 +01:00
6c07759eb7 Feature/add market data tab to admin control panel (#497)
* Add market data tab

* Update changelog
2021-11-28 19:46:34 +01:00
fcf07a0fd1 Clean up (#496) 2021-11-28 13:26:26 +01:00
2f402c0c8e Feature/introduce tabs with routing to home page (#495)
* Introduce tabs with routing

* Update changelog
2021-11-28 12:52:37 +01:00
a24a094407 Feature/introduce tabs to admin control panel (#494)
* Add tabs

* Update changelog
2021-11-28 12:34:10 +01:00
dc9b2ce194 Release 1.81.0 (#493) 2021-11-27 20:57:32 +01:00
72067459d6 Feature/add value to position detail dialog (#492)
* Add value to position detail dialog

* Update changelog
2021-11-27 09:51:08 +01:00
705441ecf8 Bugfix/fix line chart labels (#491)
* Fix line chart labels

* Fix click event for drafts

* Update changelog
2021-11-26 20:41:44 +01:00
fbd1475402 Feature/upgrade core dependencies (#490)
* Upgrade core dependencies

  * angular
  * nestjs
  * Nx
  * rxjs
  * storybook

* Temporarily fix imports for storybook

* Update changelog
2021-11-25 18:05:02 +01:00
4dc4f13f40 fix storybook after currency changed to string (#488) 2021-11-23 20:58:41 +01:00
3b857aa8bb Release 1.80.0 (#487) 2021-11-23 20:28:50 +01:00
1c2ca5b96b Feature/accentuate all time high and low (#428)
* Accentuate all time high and all time low

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-11-22 21:28:32 +01:00
572bfc59b8 Add guards (#486) 2021-11-22 20:35:25 +01:00
147f0162b7 Release 1.79.0 (#485) 2021-11-21 18:04:28 +01:00
f6acf5207b Feature/add value to positions table (#484)
* Add value column

* Update changelog
2021-11-21 17:55:58 +01:00
80782f1098 add support for euro cryptocurrencies, ALGO and remove unknown crypto… (#480)
* add support for euro cryptocurrencies, ALGO and remove unknown cryptocurrencies from list

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-11-21 17:38:48 +01:00
bc58ee86ca Feature/usability improvements in the create or edit transaction dialog (#483)
* Usability improvements
  * Disable the symbol input in edit mode
  * Filter accounts by type (SECURITIES)

* Update changelog
2021-11-20 20:41:33 +01:00
0cb632b165 Improve wording (#482) 2021-11-20 10:38:30 +01:00
fca3a659d0 Release 1.78.0 (#481) 2021-11-20 10:31:45 +01:00
904dec040e Feature/add testimonial (#479)
* Add testimonials

* Update changelog
2021-11-20 10:28:05 +01:00
fc6c81fe02 Bugfix/fix footer row border in dark mode (#478)
* Fix border color in dark mode

* Update changelog
2021-11-17 23:23:32 +01:00
634171e4e3 Release 1.77.0 (#476) 2021-11-16 21:58:41 +01:00
f8f36e4f4e Bugfix/fix accounts table footer on mobile (#475)
* Fix footer on mobile

* Update changelog
2021-11-16 21:32:04 +01:00
5e7cf9d0b6 Feature/hide get started button on registration page (#474)
* Hide button

* Update changelog
2021-11-15 20:49:03 +01:00
e1932eb5a1 Bugfix/exclude drafts from transaction count (#473)
* Fix transactions count (exclude drafts)

* Improve wording (summary page)

* Update changelog
2021-11-14 19:06:54 +01:00
dba47d59e3 Release 1.76.0 (#472) 2021-11-14 17:16:06 +01:00
3032126508 Feature/add footer row to accounts table (#471)
* Add footer row to accounts table with total balance and value

* Update changelog
2021-11-14 17:04:52 +01:00
a50b55da75 Clean up (#470) 2021-11-14 16:57:46 +01:00
5422df05b3 Release 1.75.0 (#469) 2021-11-13 20:50:02 +01:00
d2fabe7ce4 Feature/add value column to accounts table (#468)
* Add value column

* Update changelog
2021-11-13 20:38:29 +01:00
a42700b9fe Feature/introduce data gathering progress (#467)
* Add data gathering progress

* Update changelog
2021-11-13 11:32:28 +01:00
9df8541145 Feature/log logo on server start (#466)
* Log logo on server start

* Update changelog
2021-11-12 22:50:40 +01:00
0b2252755c Release 1.74.0 (#464) 2021-11-11 21:52:57 +01:00
239bd09cbd Feature/move market mood to tab (#463)
* Move market mood to tab

* Update changelog
2021-11-11 21:43:17 +01:00
cd76f89902 Feature/increase decimal places for cryptocurrencies (#462)
* Calculate quantity precision

* Update changelog
2021-11-11 21:21:37 +01:00
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
e01e039a00 Release 1.72.0 (#459) 2021-11-08 20:55:38 +01:00
6d3513c17f Update version 2021-11-07 21:27:37 +01:00
d60b444324 Release/1.71.0 (#458)
* Refactor logger

* Fix exception

* Update changelog
2021-11-07 21:25:18 +01:00
2873130259 Release 1.70.0 (#457) 2021-11-07 18:38:29 +01:00
d999a27159 Feature/move scraper configuration to symbol profile (#456)
* Move scraper configuration

* Update changelog
2021-11-07 18:36:28 +01:00
b6902e10ea Feature/improve import json file validation (#455)
* Improve import validation

* Update changelog
2021-11-07 17:16:19 +01:00
7f3f75386d Release 1.69.0 (#454) 2021-11-07 09:45:19 +01:00
678544748a Add symbol mapping (#452)
* Add symbol mapping

* Update changelog
2021-11-07 09:42:36 +01:00
632f3e3872 Add ok.csv (#453) 2021-11-06 21:09:06 +01:00
87301ddbd5 Feature/improve registration page (#451)
* Improve registration page

* Update changelog
2021-11-02 21:49:57 +01:00
7d03c373ac Release 1.68.0 (#450) 2021-11-01 21:27:23 +01:00
edb66bb166 Feature/extend statistics (#449)
* Extend statistics

* Update changelog
2021-11-01 21:15:09 +01:00
54bbc8446b Feature/prettify scraper symbol in chart (#448)
* Prettify scraper symbol in chart

* Update changelog
2021-11-01 20:29:16 +01:00
9933967e42 Release 1.67.0 (#447) 2021-11-01 19:33:45 +01:00
5618513d07 Update changelog (#446) 2021-10-31 17:02:41 +01:00
1397cd62a8 Feature/extend public page (#445)
* Extend public page

* Update changelog
2021-10-31 17:00:59 +01:00
e7fb31d1a6 add Dockerfile with all in one docker image (#431)
* Add Dockerfile with all in one docker image

* Change to alpine image and reduce node_modules size

* Improve documentation and fix changelog and license

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-30 18:54:14 +02:00
51e7b94ad0 Release 1.66.0 (#443) 2021-10-30 11:22:50 +02:00
9133ea38f3 Feature/order accesses (#441)
* Order accesses

* Update changelog
2021-10-30 10:49:17 +02:00
a864c617b9 Feature/improve landing page (#442)
* Add intro screen

* Update changelog
2021-10-30 10:46:54 +02:00
251 changed files with 8714 additions and 5594 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@
!.vscode/settings.json !.vscode/settings.json
# misc # misc
/.angular/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

View File

@ -3,9 +3,28 @@ git:
depth: false depth: false
node_js: node_js:
- 14 - 14
before_script:
- yarn services:
script: - docker
- yarn format:check
- yarn test cache: yarn
- yarn build:all
if: (type = pull_request) OR (tag IS present)
jobs:
include:
- stage: Install dependencies
if: type = pull_request
script: yarn --frozen-lockfile
- stage: Check formatting
if: type = pull_request
script: yarn format:check
- stage: Execute tests
if: type = pull_request
script: yarn test
- stage: Build application
if: type = pull_request
script: yarn build:all
- stage: Build and publish docker image
if: tag IS present
script: ./publish-docker-image.sh

3
.vscode/launch.json vendored
View File

@ -26,7 +26,8 @@
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"
] ],
"console": "integratedTerminal"
} }
] ]
} }

View File

@ -5,6 +5,298 @@ 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.93.0 - 21.12.2021
### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed
- Converted errors to warnings in portfolio calculator
## 1.92.0 - 19.12.2021
### Added
- Added a line chart to the historical data view in the admin control panel
- Supported the update of historical data in the admin control panel
### Fixed
- Improved the redirection on logout
- Fixed the permission for the system status page
## 1.91.0 - 18.12.2021
### Changed
- Removed the redundant all time high and all time low from the performance endpoint
### Fixed
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
- Fixed hidden values (`0`) in the statistics section on the about page
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.90.0 - 14.12.2021
### Added
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
- Added support for cryptocurrency _Uniswap_
- Set up pipeline for docker build
### Changed
- Removed the default transactions import limit
- Improved the landing page in dark mode
### Fixed
- Fixed `/bin/sh: prisma: not found` in docker build
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
## 1.89.0 - 11.12.2021
### Added
- Extended the data gathering by symbol endpoint with an optional date
### Changed
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
## 1.88.0 - 09.12.2021
### Added
- Added a coupon system
## 1.87.0 - 07.12.2021
### Added
- Supported the management of additional currencies in the admin control panel
- Introduced the system message
- Introduced the read only mode
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates)
- Enabled the import functionality for transactions by default
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
### Fixed
- Improved the allocations by currency in combination with cash balances
## 1.85.0 - 01.12.2021
### Fixed
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
## 1.84.0 - 30.11.2021
### Added
- Exposed the data gathering by symbol as an endpoint
## 1.83.0 - 29.11.2021
### Changed
- Removed the experimental API
### Fixed
- Eliminated the redundant storage of historical exchange rates
## 1.82.0 - 28.11.2021
### Added
- Added tabs with routing to the admin control panel
- Added a new tab to manage historical data to the admin control panel
### Changed
- Introduced tabs with routing to the home page
## 1.81.0 - 27.11.2021
### Added
- Added the value to the position detail dialog
### Changed
- Upgraded `angular` from version `12.2.4` to `13.0.2`
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
### Fixed
- Fixed the broken line charts showing value labels if openend from the allocations page
- Fixed the click event for drafts in the transactions table
## 1.80.0 - 23.11.2021
### Added
- Accentuated the all time high and the all time low
## 1.79.0 - 21.11.2021
### Added
- Added the value column to the positions table
- Added support for cryptocurrency _Algorand_
### Changed
- Locked the symbol input in the edit transaction dialog
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
### Fixed
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
## 1.78.0 - 20.11.2021
### Added
- Added a testimonial section to the landing page
### Fixed
- Fixed the footer row border of the accounts table in dark mode
## 1.77.0 - 16.11.2021
### Changed
- Hid the _Get Started_ button on the registration page
### Fixed
- Fixed the footer row of the accounts table on mobile
- Fixed the transactions count calculation in the accounts table (exclude drafts)
## 1.76.0 - 14.11.2021
### Added
- Added the footer row with buying power and net worth to the accounts table
## 1.75.0 - 13.11.2021
### Added
- Added a logo to the log on the server start
- Added the data gathering progress to the log and the admin control panel
- Added the value column to the accounts table
## 1.74.0 - 11.11.2021
### Changed
- Adapted the decimal places for cryptocurrencies in the position detail dialog
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
## 1.73.0 - 10.11.2021
### Changed
- Improved the info messages to add the first transaction
### Fixed
- Fixed the skeleton loader of the portfolio holdings
## 1.72.0 - 08.11.2021
### Changed
- Cached the statistics section on the about page
## 1.71.0 - 07.11.2021
### Changed
- Changed the logger from `console.log()` to `Logger.log()`
### Fixed
- Fixed an exception in the scraper configuration
## 1.70.0 - 07.11.2021
### Changed
- Improved the validation of `json` files in the import functionality for transactions
- Moved the scraper configuration to the symbol profile model
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.69.0 - 07.11.2021
### Added
- Added the symbol mapping attribute to the symbol profile model
### Changed
- Improved the registration page
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.68.0 - 01.11.2021
### Changed
- Prettified the generic scraper symbols in the portfolio proportion chart component
- Extended the statistics section on the about page by the active users count (7d)
- Extended the statistics section on the about page by the new users count
## 1.67.0 - 31.10.2021
### Added
- Added more details to the public page (currencies, sectors, continents and regions)
- Added a `Dockerfile` and documentation to build a _Docker_ image
## 1.66.0 - 30.10.2021
### Changed
- Improved the landing page
- Ordered the granted accesses by type
## 1.65.0 - 25.10.2021 ## 1.65.0 - 25.10.2021
### Added ### Added
@ -20,7 +312,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _SHIBA INU_ etc. - Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
### Changed ### Changed
@ -38,7 +330,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.62.0 - 17.10.2021 ## 1.62.0 - 17.10.2021
@ -136,7 +428,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.55.0 - 20.09.2021 ## 1.55.0 - 20.09.2021
@ -151,7 +443,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.54.0 - 18.09.2021 ## 1.54.0 - 18.09.2021
@ -172,7 +464,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.53.0 - 13.09.2021 ## 1.53.0 - 13.09.2021
@ -294,7 +586,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.41.0 - 21.08.2021 ## 1.41.0 - 21.08.2021
@ -347,7 +639,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.38.0 - 14.08.2021 ## 1.38.0 - 14.08.2021
@ -407,7 +699,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn prisma migrate deploy`) - Apply data migration (`yarn database:migrate`)
## 1.34.0 - 07.08.2021 ## 1.34.0 - 07.08.2021

52
Dockerfile Normal file
View File

@ -0,0 +1,52 @@
FROM node:14-alpine as builder
# Build application and add additional files
WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.js jest.config.js
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:all
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
RUN yarn
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]

View File

@ -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 the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood). If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
## Why Ghostfolio? ## Why Ghostfolio?
@ -81,13 +81,66 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started ## Run with Docker (self-hosting)
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
### a. Run environment
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash
docker-compose -f docker/docker-compose.yml up
```
### b. Build and run environment
Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up
```
### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
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_
### 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
With the following command you can keep your database schema in sync after a Ghostfolio version update:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
## Development
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup ### Setup
@ -95,24 +148,20 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `cd docker` 1. Run `cd docker`
1. Run `docker compose 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 `cd -` to go back to the project root directory
1. Run `yarn setup:database` 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 server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
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_
## Development ### Start Server
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
<ol type="a"> <ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li> <li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li> <li>Serve: Run <code>yarn start:server</code></li>
</ol> </ol>
### Start client ### Start Client
Run `yarn start:client` Run `yarn start:client`

View File

@ -1,22 +1,5 @@
{ {
"version": 1, "version": 1,
"cli": {
"defaultCollection": "@nrwl/nest"
},
"defaultProject": "api",
"schematics": {
"@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress"
},
"@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nrwl/nest": {},
"@nrwl/angular:component": {}
},
"projects": { "projects": {
"api": { "api": {
"root": "apps/api", "root": "apps/api",
@ -35,6 +18,7 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"generatePackageJson": true,
"optimization": true, "optimization": true,
"extractLicenses": true, "extractLicenses": true,
"inspect": false, "inspect": false,
@ -68,7 +52,8 @@
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
} }
} },
"tags": []
}, },
"client": { "client": {
"projectType": "application", "projectType": "application",
@ -99,12 +84,12 @@
{ {
"glob": "CHANGELOG.md", "glob": "CHANGELOG.md",
"input": "", "input": "",
"output": "./" "output": "./assets"
}, },
{ {
"glob": "LICENSE", "glob": "LICENSE",
"input": "", "input": "",
"output": "./" "output": "./assets"
}, },
{ {
"glob": "robots.txt", "glob": "robots.txt",
@ -200,7 +185,8 @@
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
} }
} },
"tags": []
}, },
"client-e2e": { "client-e2e": {
"root": "apps/client-e2e", "root": "apps/client-e2e",
@ -220,7 +206,9 @@
} }
} }
} }
} },
"tags": [],
"implicitDependencies": ["client"]
}, },
"common": { "common": {
"root": "libs/common", "root": "libs/common",
@ -241,7 +229,8 @@
"passWithNoTests": true "passWithNoTests": true
} }
} }
} },
"tags": []
}, },
"ui": { "ui": {
"projectType": "library", "projectType": "library",
@ -299,7 +288,8 @@
} }
} }
} }
} },
"tags": []
}, },
"ui-e2e": { "ui-e2e": {
"root": "apps/ui-e2e", "root": "apps/ui-e2e",
@ -325,7 +315,9 @@
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"] "lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
} }
} }
} },
"tags": [],
"implicitDependencies": ["ui"]
} }
} }
} }

View File

@ -1,9 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -39,6 +35,7 @@ export class AccessController {
include: { include: {
GranteeUser: true GranteeUser: true
}, },
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
@ -65,10 +62,7 @@ export class AccessController {
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccess)
getPermissions(this.request.user.role),
permissions.createAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,10 +79,7 @@ export class AccessController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccess)
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -24,7 +24,7 @@ export class AccessService {
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput; cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput; orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;

View File

@ -1,11 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
getPermissions, nullifyValuesInObject,
hasPermission, nullifyValuesInObjects
permissions } from '@ghostfolio/api/helper/object.helper';
} from '@ghostfolio/common/permissions'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -34,6 +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,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -42,10 +44,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccount)
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,30 +84,39 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> { ): Promise<Accounts> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
let accounts = await this.accountService.getAccounts( let accountsWithAggregations =
impersonationUserId || this.request.user.id await this.portfolioService.getAccountsWithAggregations(
); impersonationUserId || this.request.user.id
);
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
accounts = nullifyValuesInObjects(accounts, [ accountsWithAggregations = {
'balance', ...nullifyValuesInObject(accountsWithAggregations, [
'fee', 'totalBalance',
'quantity', 'totalValue'
'unitPrice' ]),
]); accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
} }
return accounts; return accountsWithAggregations;
} }
@Get(':id') @Get(':id')
@ -128,10 +136,7 @@ export class AccountController {
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccount)
getPermissions(this.request.user.role),
permissions.createAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -168,10 +173,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateAccount)
getPermissions(this.request.user.role),
permissions.updateAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -1,3 +1,4 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@Module({ @Module({
controllers: [AccountController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
RedisCacheModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule,
UserModule UserModule
], ],
controllers: [AccountController],
providers: [AccountService] providers: [AccountService]
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -40,7 +40,7 @@ export class AccountService {
take?: number; take?: number;
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; Order?: Order[];
@ -85,7 +85,15 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length }; let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order; delete result.Order;

View File

@ -1,30 +1,39 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
getPermissions, AdminData,
hasPermission, AdminMarketData,
permissions AdminMarketDataDetails
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param,
Post, Post,
Put,
UseGuards UseGuards
} 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 { DataSource, MarketData } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -33,7 +42,7 @@ export class AdminController {
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -51,7 +60,7 @@ export class AdminController {
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -72,7 +81,7 @@ export class AdminController {
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -86,4 +95,154 @@ export class AdminController {
return; return;
} }
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.dataGatheringService.gatherSymbolForDate({
dataSource,
date,
symbol
});
}
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol(symbol);
}
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
return this.marketDataService.updateMarketData({
data,
where: {
date_symbol: {
date,
symbol
}
}
});
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
} }

View File

@ -3,7 +3,9 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -15,7 +17,9 @@ import { AdminService } from './admin.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],

View File

@ -2,10 +2,17 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-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 { baseCurrency } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
@ -14,12 +21,16 @@ export class AdminService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
@ -37,12 +48,55 @@ export class AdminService {
}; };
}), }),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
}; };
} }
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
})
};
}
public async getMarketDataBySymbol(
aSymbol: string
): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
}
})
};
}
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
response = await this.propertyService.put({ key, value });
}
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = const lastDataGathering =
await this.dataGatheringService.getLastDataGathering(); await this.dataGatheringService.getLastDataGathering();
@ -58,7 +112,7 @@ export class AdminService {
return 'IN_PROGRESS'; return 'IN_PROGRESS';
} }
return null; return undefined;
} }
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {

View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class UpdateMarketDataDto {
@IsNumber()
marketPrice: number;
}

View File

@ -19,7 +19,6 @@ import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
@ -42,7 +41,6 @@ import { UserModule } from './user/user.module';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExperimentalModule,
ExportModule, ExportModule,
ImportModule, ImportModule,
InfoModule, InfoModule,

View File

@ -1,9 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -29,7 +25,7 @@ export class AuthDeviceController {
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.deleteAuthDevice permissions.deleteAuthDevice
) )
) { ) {

View File

@ -23,7 +23,7 @@ export class AuthDeviceService {
take?: number; take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput; cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput; where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.authDevice.findMany({ return this.prismaService.authDevice.findMany({

View File

@ -1,7 +1,7 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -19,7 +19,8 @@ import { JwtStrategy } from './jwt.strategy';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
SubscriptionModule SubscriptionModule,
UserModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,
@ -28,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService,
WebAuthService WebAuthService
] ]
}) })

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20'; import { Strategy } from 'passport-google-oauth20';
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
}; };
done(null, user); done(null, user);
} catch (err) { } catch (error) {
console.error(err); Logger.error(error);
done(err, false); done(error, false);
} }
} }
} }

View File

@ -6,7 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException InternalServerErrorException,
Logger
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -94,7 +95,7 @@ export class WebAuthService {
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -192,7 +193,7 @@ export class WebAuthService {
}; };
verification = verifyAuthenticationResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

View File

@ -6,6 +6,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
@ -15,7 +16,8 @@ import { CacheController } from './cache.controller';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
RedisCacheModule RedisCacheModule,
SymbolProfileModule
], ],
controllers: [CacheController], controllers: [CacheController],
providers: [ providers: [

View File

@ -1,22 +0,0 @@
import { Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()
currency: string;
@IsISO8601()
date: string;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

View File

@ -1,69 +0,0 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Post
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { parse } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { ExperimentalService } from './experimental.service';
import { Data } from './interfaces/data.interface';
@Controller('experimental')
export class ExperimentalController {
public constructor(
private readonly experimentalService: ExperimentalService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('benchmarks')
public async getBenchmarks(
@Headers('Authorization') apiToken: string
): Promise<string[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return benchmarks.map(({ symbol }) => {
return symbol;
});
}
@Get('benchmarks/:symbol')
public async getBenchmark(
@Headers('Authorization') apiToken: string,
@Param('symbol') symbol: string
): Promise<{ date: Date; marketPrice: number }[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const marketData = await this.experimentalService.getBenchmark(symbol);
if (marketData?.length === 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return marketData;
}
}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
PrismaModule
],
controllers: [ExperimentalController],
providers: [AccountService, ExperimentalService]
})
export class ExperimentalModule {}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async getBenchmark(aSymbol: string) {
return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true },
where: { symbol: aSymbol }
});
}
}

View File

@ -1,4 +0,0 @@
export interface Data {
currency: string;
value: number;
}

View File

@ -5,6 +5,7 @@ import {
Controller, Controller,
HttpException, HttpException,
Inject, Inject,
Logger,
Post, Post,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -39,7 +40,7 @@ export class ImportController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new HttpException( throw new HttpException(
{ {

View File

@ -1,4 +1,5 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
@ -6,9 +7,8 @@ import { isSameDay, parseISO } from 'date-fns';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
private static MAX_ORDERS_TO_IMPORT = 20;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService private readonly orderService: OrderService
) {} ) {}
@ -59,8 +59,14 @@ export class ImportService {
orders: Partial<Order>[]; orders: Partial<Order>[];
userId: string; userId: string;
}) { }) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) { if (
throw new Error('Too many transactions'); orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many transactions (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
} }
const existingOrders = await this.orderService.orders({ const existingOrders = await this.orderService.orders({
@ -98,6 +104,12 @@ export class ImportService {
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (result[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
);
}
} }
} }
} }

View File

@ -1,9 +1,12 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
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 { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -18,7 +21,10 @@ import { InfoService } from './info.service';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
PropertyModule,
RedisCacheModule,
SymbolProfileModule
], ],
controllers: [InfoController], controllers: [InfoController],
providers: [ providers: [

View File

@ -1,18 +1,27 @@
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 { 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 {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
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 } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
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 DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
public constructor( public constructor(
@ -21,15 +30,19 @@ export class InfoService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({ const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
let systemMessage: string;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -41,6 +54,12 @@ export class InfoService {
globalPermissions.push(permissions.enableImport); globalPermissions.push(permissions.enableImport);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
@ -55,10 +74,20 @@ export class InfoService {
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
}
return { return {
...info, ...info,
globalPermissions, globalPermissions,
isReadOnlyMode,
platforms, platforms,
systemMessage,
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
@ -109,7 +138,7 @@ export class InfoService {
const contributors = await get(); const contributors = await get();
return contributors?.length; return contributors?.length;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; return undefined;
} }
@ -130,12 +159,34 @@ export class InfoService {
const { stargazers_count } = await get(); const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; return undefined;
} }
} }
private async countNewUsers(aDays: number) {
return await this.prismaService.user.count({
orderBy: {
createdAt: 'desc'
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
createdAt: {
gt: subDays(new Date(), aDays)
}
}
]
}
});
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -154,17 +205,40 @@ export class InfoService {
return undefined; return undefined;
} }
let statistics: Statistics;
try {
statistics = JSON.parse(
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
);
if (statistics) {
return statistics;
}
} 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 gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
return { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubContributors, gitHubContributors,
gitHubStargazers gitHubStargazers,
newUsers30d
}; };
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
return statistics;
} }
private async getSubscriptions(): Promise<Subscription[]> { private async getSubscriptions(): Promise<Subscription[]> {
@ -173,7 +247,7 @@ export class InfoService {
} }
const stripeConfig = await this.prismaService.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: PROPERTY_STRIPE_CONFIG }
}); });
if (stripeConfig) { if (stripeConfig) {

View File

@ -1,11 +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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -43,10 +39,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteOrder)
getPermissions(this.request.user.role),
permissions.deleteOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -115,10 +108,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createOrder)
getPermissions(this.request.user.role),
permissions.createOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -161,10 +151,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateOrder)
getPermissions(this.request.user.role),
permissions.updateOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -28,7 +28,7 @@ export class OrderService {
take?: number; take?: number;
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -45,19 +45,22 @@ export class OrderService {
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> { public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
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
const symbol = data.symbol.toUpperCase();
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
symbol,
dataSource: data.dataSource, dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date
symbol: data.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([ this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol } { symbol, dataSource: data.dataSource }
]); ]);
await this.cacheService.flush(); await this.cacheService.flush();
@ -65,7 +68,8 @@ export class OrderService {
return this.prismaService.order.create({ return this.prismaService.order.create({
data: { data: {
...data, ...data,
isDraft isDraft,
symbol
} }
}); });
} }

View File

@ -1,11 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data.service', () => {
return { return {
MarketDataService: jest.fn().mockImplementation(() => { MarketDataService: jest.fn().mockImplementation(() => {
return { return {
@ -73,7 +73,7 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null); exchangeRateDataService = new ExchangeRateDataService(null, null, null);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();

View File

@ -1,5 +1,6 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
@ -8,7 +9,6 @@ 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 { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { MarketDataService } from './market-data.service';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {

View File

@ -1,5 +1,4 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { DataSource, Type as TypeOfOrder } from '@prisma/client';
import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder {
@ -10,6 +9,6 @@ export interface PortfolioOrder {
name: string; name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;
type: OrderType; type: TypeOfOrder;
unitPrice: Big; unitPrice: Big;
} }

View File

@ -1,4 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number; averagePrice: number;
currency: string; currency: string;
firstBuyDate: string; firstBuyDate: string;
@ -15,6 +19,13 @@ export interface PortfolioPositionDetail {
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;
value: number;
}
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
} }
export interface HistoricalDataItem { export interface HistoricalDataItem {

View File

@ -0,0 +1,8 @@
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
import Big from 'big.js';
export interface TimelineInfoInterface {
maxNetPerformance: Big;
minNetPerformance: Big;
timelinePeriods: TimelinePeriod[];
}

View File

@ -1,4 +1,3 @@
import { OrderType } from '@ghostfolio/api/models/order-type';
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
@ -155,7 +154,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('144.38'), unitPrice: new Big('144.38'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -166,7 +165,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('147.99'), unitPrice: new Big('147.99'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -177,7 +176,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'), quantity: new Big('15'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Sell, type: 'SELL',
unitPrice: new Big('151.41'), unitPrice: new Big('151.41'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -248,7 +247,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('144.38'), unitPrice: new Big('144.38'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -259,7 +258,7 @@ describe('PortfolioCalculator', () => {
name: 'Something else', name: 'Something else',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTX', symbol: 'VTX',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('147.99'), unitPrice: new Big('147.99'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -270,7 +269,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Sell, type: 'SELL',
unitPrice: new Big('151.41'), unitPrice: new Big('151.41'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -360,7 +359,7 @@ describe('PortfolioCalculator', () => {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'), quantity: new Big('20'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('197.15'), unitPrice: new Big('197.15'),
fee: new Big(0) fee: new Big(0)
} }
@ -462,7 +461,7 @@ describe('PortfolioCalculator', () => {
name: 'Amazon.com, Inc.', name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('2021.99'), unitPrice: new Big('2021.99'),
fee: new Big(0) fee: new Big(0)
} }
@ -617,7 +616,7 @@ describe('PortfolioCalculator', () => {
name: 'Amazon.com, Inc.', name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('2021.99'), unitPrice: new Big('2021.99'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -628,7 +627,7 @@ describe('PortfolioCalculator', () => {
name: 'Amazon.com, Inc.', name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
type: OrderType.Sell, type: 'SELL',
unitPrice: new Big('2412.23'), unitPrice: new Big('2412.23'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -1502,11 +1501,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1622,11 +1621,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1665,11 +1664,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month' accuracy: 'month'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1883,6 +1882,9 @@ describe('PortfolioCalculator', () => {
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08) value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
} }
]); ]);
expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9'));
expect(timelineInfo.minNetPerformance).toEqual(new Big('0'));
}); });
it('with yearly and monthly mixed', async () => { it('with yearly and monthly mixed', async () => {
@ -1901,11 +1903,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month' accuracy: 'month'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1987,11 +1989,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'day' accuracy: 'day'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual( expect(timeline).toEqual(
expect.objectContaining([ expect.objectContaining([
@ -2296,11 +2298,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2020-01-01'
'2020-01-01' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -2388,7 +2390,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
name: 'Tesla, Inc.', name: 'Tesla, Inc.',
quantity: new Big('50'), quantity: new Big('50'),
symbol: 'TSLA', symbol: 'TSLA',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('42.97'), unitPrice: new Big('42.97'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2399,7 +2401,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
name: 'Bitcoin USD', name: 'Bitcoin USD',
quantity: new Big('0.5614682'), quantity: new Big('0.5614682'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('3562.089535970158'), unitPrice: new Big('3562.089535970158'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2410,7 +2412,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
name: 'Amazon.com, Inc.', name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('2021.99'), unitPrice: new Big('2021.99'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2424,7 +2426,7 @@ const ordersVTI: PortfolioOrder[] = [
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('144.38'), unitPrice: new Big('144.38'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2435,7 +2437,7 @@ const ordersVTI: PortfolioOrder[] = [
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('147.99'), unitPrice: new Big('147.99'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2446,7 +2448,7 @@ const ordersVTI: PortfolioOrder[] = [
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'), quantity: new Big('15'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Sell, type: 'SELL',
unitPrice: new Big('151.41'), unitPrice: new Big('151.41'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2457,7 +2459,7 @@ const ordersVTI: PortfolioOrder[] = [
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('177.69'), unitPrice: new Big('177.69'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
@ -2468,7 +2470,7 @@ const ordersVTI: PortfolioOrder[] = [
name: 'Vanguard Total Stock Market Index Fund ETF Shares', name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
type: OrderType.Buy, type: 'BUY',
unitPrice: new Big('203.15'), unitPrice: new Big('203.15'),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,

View File

@ -1,7 +1,9 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
@ -236,9 +238,7 @@ export class PortfolioCalculator {
if (!marketSymbolMap[nextDate]?.[item.symbol]) { if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
console.error( Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
continue; continue;
} }
let lastInvestment: Big = new Big(0); let lastInvestment: Big = new Big(0);
@ -269,7 +269,7 @@ export class PortfolioCalculator {
if (!initialValue) { if (!initialValue) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
console.error( Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}` `Missing value for symbol ${item.symbol} at ${currentDate}`
); );
continue; continue;
@ -364,16 +364,20 @@ export class PortfolioCalculator {
public async calculateTimeline( public async calculateTimeline(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
endDate: string endDate: string
): Promise<TimelinePeriod[]> { ): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) { if (timelineSpecification.length === 0) {
return []; return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
} }
const startDate = timelineSpecification[0].start; const startDate = timelineSpecification[0].start;
const start = parseDate(startDate); const start = parseDate(startDate);
const end = parseDate(endDate); const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = []; const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0; let i = 0;
let j = -1; let j = -1;
for ( for (
@ -416,11 +420,40 @@ export class PortfolioCalculator {
} }
} }
const timelinePeriods: TimelinePeriod[][] = await Promise.all( const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises timelinePeriodPromises
); );
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
return flatten(timelinePeriods); 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( private calculateOverallPerformance(
@ -480,8 +513,8 @@ export class PortfolioCalculator {
currentPosition.netPerformancePercentage.mul(currentInitialValue) currentPosition.netPerformancePercentage.mul(currentInitialValue)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
console.error( Logger.warn(
`Initial value is missing for symbol ${currentPosition.symbol}` `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
); );
hasErrors = true; hasErrors = true;
} }
@ -512,7 +545,7 @@ export class PortfolioCalculator {
j: number, j: number,
startDate: Date, startDate: Date,
endDate: Date endDate: Date
): Promise<TimelinePeriod[]> { ): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0); let investment: Big = new Big(0);
let fees: Big = new Big(0); let fees: Big = new Big(0);
@ -546,7 +579,7 @@ export class PortfolioCalculator {
userCurrency: this.currency userCurrency: this.currency
}); });
} catch (error) { } catch (error) {
console.error( Logger.error(
`Failed to fetch info for date ${startDate} with exception`, `Failed to fetch info for date ${startDate} with exception`,
error error
); );
@ -568,6 +601,8 @@ export class PortfolioCalculator {
} }
const results: TimelinePeriod[] = []; const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for ( for (
let currentDate = startDate; let currentDate = startDate;
isBefore(currentDate, endDate); isBefore(currentDate, endDate);
@ -591,28 +626,46 @@ export class PortfolioCalculator {
} }
if (!invalid) { if (!invalid) {
const grossPerformance = value.minus(investment); 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 = { const result = {
grossPerformance, grossPerformance,
investment, investment,
netPerformance,
value, value,
date: currentDateAsString, date: currentDateAsString
netPerformance: grossPerformance.minus(fees)
}; };
results.push(result); results.push(result);
} }
} }
return results; return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
} }
private getFactor(type: OrderType) { private getFactor(type: TypeOfOrder) {
let factor: number; let factor: number;
switch (type) { switch (type) {
case OrderType.Buy: case 'BUY':
factor = 1; factor = 1;
break; break;
case OrderType.Sell: case 'SELL':
factor = -1; factor = -1;
break; break;
default: default:

View File

@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
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 { import {
PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -32,10 +33,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express'; import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
HistoricalDataItem,
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 { PortfolioService } from './portfolio.service';
@ -92,12 +90,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<HistoricalDataItem[]> { ): Promise<PortfolioChart> {
let chartData = await this.portfolioService.getChart( const historicalDataContainer = await this.portfolioService.getChart(
impersonationId, impersonationId,
range range
); );
let chartData = historicalDataContainer.items;
let hasNullValue = false; let hasNullValue = false;
chartData.forEach((chartDataItem) => { chartData.forEach((chartDataItem) => {
@ -130,7 +130,11 @@ export class PortfolioController {
}); });
} }
return <any>res.json(chartData); return <any>res.json({
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
} }
@Get('details') @Get('details')
@ -266,18 +270,27 @@ export class PortfolioController {
@Res() res: Response @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({
id: access.userId
});
if (!access) { if (!access) {
res.status(StatusCodes.NOT_FOUND); res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} }); return <any>res.json({ accounts: {}, holdings: {} });
} }
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails( const { holdings } = await this.portfolioService.getDetails(
access.userId, access.userId,
access.userId access.userId
); );
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
holdings: {} holdings: {}
}; };
@ -298,9 +311,10 @@ export class PortfolioController {
if (portfolioPosition.assetClass === 'EQUITY') { if (portfolioPosition.assetClass === 'EQUITY') {
portfolioPublicDetails.holdings[symbol] = { portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.allocationCurrent, allocationCurrent: portfolioPosition.allocationCurrent,
countries: [], countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
name: portfolioPosition.name, name: portfolioPosition.name,
sectors: [], sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue value: portfolioPosition.value / totalValue
}; };
} }
@ -356,7 +370,8 @@ export class PortfolioController {
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance', 'netPerformance',
'quantity' 'quantity',
'value'
]); ]);
} }

View File

@ -7,17 +7,18 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
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 { 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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,
@ -25,18 +26,18 @@ import { RulesService } from './rules.service';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
SymbolProfileModule,
UserModule UserModule
], ],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
AccountService, AccountService,
CurrentRateService, CurrentRateService,
MarketDataService,
PortfolioService, PortfolioService,
RulesService, RulesService
SymbolProfileService
] ]
}) })
export class PortfolioModule {} export class PortfolioModule {}

View File

@ -1,5 +1,3 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -8,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -23,13 +20,10 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
UNKNOWN_KEY,
baseCurrency,
ghostfolioCashSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioReport, PortfolioReport,
@ -39,6 +33,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
AccountWithValue,
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -56,12 +51,14 @@ import {
parse, parse,
parseISO, parseISO,
setDayOfYear, setDayOfYear,
startOfDay,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
HistoricalDataContainer,
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
@ -81,6 +78,59 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
}),
this.getDetails(aUserId, aUserId)
]);
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.name]?.current ?? 0
};
delete result.Order;
return result;
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
}
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
@ -111,7 +161,7 @@ export class PortfolioService {
public async getChart( public async getChart(
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> { ): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
@ -122,14 +172,21 @@ export class PortfolioService {
const { transactionPoints } = await this.getTransactionPoints({ userId }); const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
} }
let portfolioStart = parse( let portfolioStart = parse(
transactionPoints[0].date, transactionPoints[0].date,
DATE_FORMAT, DATE_FORMAT,
new Date() new Date()
); );
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [ const timelineSpecification: TimelineSpecification[] = [
{ {
@ -138,18 +195,52 @@ export class PortfolioService {
} }
]; ];
const timeline = await portfolioCalculator.calculateTimeline( const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification, timelineSpecification,
format(new Date(), DATE_FORMAT) format(new Date(), DATE_FORMAT)
); );
return timeline const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null) .filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({ .map((timelineItem) => ({
date: timelineItem.date, date: timelineItem.date,
marketPrice: timelineItem.value, marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber() value: timelineItem.netPerformance.toNumber()
})); }));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
);
return {
isAllTimeHigh,
isAllTimeLow,
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
} }
public async getDetails( public async getDetails(
@ -251,14 +342,18 @@ export class PortfolioService {
}; };
} }
// TODO: Add a cash position for each currency const cashPositions = await this.getCashPositions({
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails, cashDetails,
userCurrency,
investment: totalInvestment, investment: totalInvestment,
value: totalValue value: totalValue
}); });
const accounts = await this.getAccounts( for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
@ -295,10 +390,13 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined,
value: undefined
}; };
} }
const assetClass = orders[0].SymbolProfile?.assetClass;
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
@ -310,7 +408,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
type: <OrderType>order.type, type: order.type,
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
@ -412,6 +510,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
currency, currency,
firstBuyDate, firstBuyDate,
grossPerformance, grossPerformance,
@ -427,7 +527,12 @@ export class PortfolioService {
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(), netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
symbol: aSymbol symbol: aSymbol,
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
currency,
userCurrency
)
}; };
} else { } else {
const currentData = await this.dataProviderService.get([ const currentData = await this.dataProviderService.get([
@ -467,6 +572,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
@ -482,7 +589,8 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
quantity: 0, quantity: 0,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined,
value: 0
}; };
} }
} }
@ -613,7 +721,7 @@ export class PortfolioService {
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance, currentNetPerformance,
currentNetPerformancePercent, currentNetPerformancePercent,
currentValue: currentValue currentValue
} }
}; };
} }
@ -663,7 +771,7 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getAccounts( const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
currency, currency,
@ -737,8 +845,8 @@ export class PortfolioService {
const fees = this.getFees(orders); const fees = this.getFees(orders);
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY); const totalBuy = this.getTotalByType(orders, currency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL); const totalSell = this.getTotalByType(orders, currency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell); const committedFunds = new Big(totalBuy).sub(totalSell);
@ -759,38 +867,73 @@ export class PortfolioService {
}; };
} }
private async getCashPosition({ private async getCashPositions({
cashDetails, cashDetails,
investment, investment,
userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big; investment: Big;
value: Big; value: Big;
userCurrency: string;
}) { }) {
const cashValue = new Big(cashDetails.balance); const cashPositions = {};
return { for (const account of cashDetails.accounts) {
allocationCurrent: cashValue.div(value).toNumber(), const convertedBalance = this.exchangeRateDataService.toCurrency(
allocationInvestment: cashValue.div(investment).toNumber(), account.balance,
assetClass: AssetClass.CASH, account.currency,
assetSubClass: AssetClass.CASH, userCurrency
countries: [], );
currency: 'CHF',
grossPerformance: 0, if (convertedBalance === 0) {
grossPerformancePercent: 0, continue;
investment: cashValue.toNumber(), }
marketPrice: 0,
marketState: MarketState.open, if (cashPositions[account.currency]) {
name: 'Cash', cashPositions[account.currency].investment += convertedBalance;
netPerformance: 0, cashPositions[account.currency].value += convertedBalance;
netPerformancePercent: 0, } else {
quantity: 0, cashPositions[account.currency] = {
sectors: [], allocationCurrent: 0,
symbol: ghostfolioCashSymbol, allocationInvestment: 0,
transactionCount: 0, assetClass: AssetClass.CASH,
value: cashValue.toNumber() assetSubClass: AssetClass.CASH,
}; countries: [],
currency: account.currency,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: MarketState.open,
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: account.currency,
transactionCount: 0,
value: convertedBalance
};
}
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(
cashPositions[symbol].value
)
.div(value)
.toNumber();
cashPositions[symbol].allocationInvestment = new Big(
cashPositions[symbol].investment
)
.div(investment)
.toNumber();
}
return cashPositions;
} }
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
@ -842,7 +985,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
type: <OrderType>order.type, type: order.type,
unitPrice: new Big( unitPrice: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
order.unitPrice, order.unitPrice,
@ -863,7 +1006,7 @@ export class PortfolioService {
}; };
} }
private async getAccounts( private async getValueOfAccounts(
orders: OrderWithAccount[], orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition }, portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: string, userCurrency: string,
@ -878,32 +1021,22 @@ export class PortfolioService {
return accountId === account.id; return accountId === account.id;
}); });
if (ordersByAccount.length <= 0) { const convertedBalance = this.exchangeRateDataService.toCurrency(
// Add account without orders account.balance,
const balance = this.exchangeRateDataService.toCurrency( account.currency,
account.balance, userCurrency
account.currency, );
userCurrency accounts[account.name] = {
); balance: convertedBalance,
accounts[account.name] = { currency: account.currency,
current: balance, current: convertedBalance,
original: balance original: convertedBalance
}; };
continue;
}
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency( let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice, order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.currency, let originalValueOfSymbol = order.quantity * order.unitPrice;
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') { if (order.type === 'SELL') {
currentValueOfSymbol *= -1; currentValueOfSymbol *= -1;
@ -917,6 +1050,8 @@ export class PortfolioService {
originalValueOfSymbol; originalValueOfSymbol;
} else { } else {
accounts[order.Account?.name || UNKNOWN_KEY] = { accounts[order.Account?.name || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbol,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };

View File

@ -21,9 +21,9 @@ export class RedisCacheService {
await this.cache.reset(); await this.cache.reset();
} }
public async set(key: string, value: string) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(key, value, { await this.cache.set(key, value, {
ttl: this.configurationService.get('CACHE_TTL') ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
}); });
} }
} }

View File

@ -1,4 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -6,6 +9,7 @@ import {
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Post, Post,
Req, Req,
Res, Res,
@ -13,6 +17,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { 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';
@ -21,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
export class SubscriptionController { export class SubscriptionController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post('redeem-coupon')
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
});
if (!isValid) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
value: JSON.stringify(coupons)
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
res.status(StatusCodes.OK);
return <any>res.json({
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
}
@Get('stripe/callback') @Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) { public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription( const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId req.query.checkoutSessionId
); );
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
} }
@ -46,7 +105,7 @@ export class SubscriptionController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -1,12 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller'; import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [PropertyModule],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService], providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService] exports: [SubscriptionService]

View File

@ -1,8 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns'; import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@ -64,28 +64,34 @@ export class SubscriptionService {
}; };
} }
public async createSubscription(aCheckoutSessionId: string) { public async createSubscription(aUserId: string) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: aUserId
}
}
}
});
}
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId aCheckoutSessionId
); );
await this.prismaService.subscription.create({ await this.createSubscription(session.client_reference_id);
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, { await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id description: session.client_reference_id
}); });
return session.client_reference_id;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
} }

View File

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

View File

@ -1,18 +1,21 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards
} 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 { DataSource } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -51,7 +54,9 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
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)
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
@ -60,7 +65,10 @@ export class SymbolController {
); );
} }
const result = await this.symbolService.get({ dataSource, symbol }); const result = await this.symbolService.get({
includeHistoricalData,
dataGatheringItem: { dataSource, symbol }
});
if (!result || isEmpty(result)) { if (!result || isEmpty(result)) {
throw new HttpException( throw new HttpException(
@ -71,4 +79,27 @@ export class SymbolController {
return result; return result;
} }
@Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.symbolService.getForDate({
dataSource,
date,
symbol
});
}
} }

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { 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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController], controllers: [SymbolController],
providers: [SymbolService] providers: [SymbolService]
}) })

View File

@ -1,8 +1,15 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
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 { Injectable } from '@nestjs/common'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,16 +18,42 @@ 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 prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> { public async get({
dataGatheringItem,
includeHistoricalData = false
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
}): 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[];
if (includeHistoricalData) {
const days = 10;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
historicalData = marketData.map(({ date, marketPrice }) => {
return {
date: date.toISOString(),
value: marketPrice
};
});
}
return { return {
currency, currency,
historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource
}; };
@ -29,6 +62,27 @@ export class SymbolService {
return undefined; return undefined;
} }
public async getForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
return {
marketPrice:
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
};
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] }; const results: { items: LookupItem[] } = { items: [] };
@ -67,7 +121,7 @@ export class SymbolService {
return results; return results;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw error; throw error;
} }

View File

@ -1,7 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import {
getPermissions,
hasPermission, hasPermission,
hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -34,7 +37,9 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -43,10 +48,7 @@ export class UserController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> { public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteUser) ||
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id id === this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
@ -68,6 +70,19 @@ export class UserController {
@Post() @Post()
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
const { accessToken, id } = await this.userService.createUser({ const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS provider: Provider.ANONYMOUS
}); });
@ -85,7 +100,7 @@ export class UserController {
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -111,7 +126,7 @@ export class UserController {
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -127,10 +142,7 @@ export class UserController {
}; };
if ( if (
hasPermission( hasPermission(this.request.user.permissions, permissions.updateViewMode)
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) { ) {
userSettings.viewMode = data.viewMode; userSettings.viewMode = data.viewMode;
} }

View File

@ -1,6 +1,7 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -13,6 +14,7 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],

View File

@ -1,12 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency, locale } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -20,6 +29,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@ -74,12 +84,32 @@ export class UserService {
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role); let currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex); currentPermissions.push(permissions.accessFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
}
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => {
return !(
permission.startsWith('create') ||
permission.startsWith('delete') ||
permission.startsWith('update')
);
});
}
}
user.permissions = currentPermissions; user.permissions = currentPermissions;
if (userFromDatabase?.Settings) { if (userFromDatabase?.Settings) {
@ -119,7 +149,7 @@ export class UserService {
take?: number; take?: number;
cursor?: Prisma.UserWhereUniqueInput; cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput; where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.user.findMany({ return this.prismaService.user.findMany({

View File

@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -18,8 +19,21 @@ async function bootstrap() {
const port = process.env.PORT || 3333; const port = process.env.PORT || 3333;
await app.listen(port, () => { await app.listen(port, () => {
logLogo();
Logger.log(`Listening at http://localhost:${port}`); Logger.log(`Listening at http://localhost:${port}`);
Logger.log('');
}); });
} }
function logLogo() {
Logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
);
Logger.log('');
}
bootstrap(); bootstrap();

View File

@ -1,8 +0,0 @@
export enum OrderType {
CorporateAction = 'CORPORATE_ACTION',
Bonus = 'BONUS',
Buy = 'BUY',
Dividend = 'DIVIDEND',
Sell = 'SELL',
Split = 'SPLIT'
}

View File

@ -1,8 +1,7 @@
import { Account, SymbolProfile } from '@prisma/client'; import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account; private account: Account;
@ -15,7 +14,7 @@ export class Order {
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
private total: number; private total: number;
private type: OrderType; private type: TypeOfOrder;
private unitPrice: number; private unitPrice: number;
public constructor(data: IOrder) { public constructor(data: IOrder) {

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -18,14 +17,17 @@ export class ConfigurationService {
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 }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }), ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
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 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),

View File

@ -10,7 +10,7 @@ export class CryptocurrencyService {
public constructor() {} public constructor() {}
public isCrypto(aSymbol = '') { public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol); return this.getCryptocurrencies().includes(cryptocurrencySymbol);
} }

View File

@ -1,6 +1,9 @@
{ {
"1INCH": "1inch", "1INCH": "1inch",
"ALGO": "Algorand",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"MATIC": "Polygon", "MATIC": "Polygon",
"SHIB": "Shiba Inu" "SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"
} }

View File

@ -6,6 +6,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
@ -13,7 +14,8 @@ import { ExchangeRateDataModule } from './exchange-rate-data.module';
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PrismaModule PrismaModule,
SymbolProfileModule
], ],
providers: [DataGatheringService], providers: [DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService] exports: [DataEnhancerModule, DataGatheringService]

View File

@ -1,10 +1,12 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
benchmarks, PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING,
ghostfolioFearAndGreedIndexSymbol 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 } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -17,7 +19,6 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.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';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
@ -25,26 +26,28 @@ import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService, private readonly prismaService: PrismaService,
private readonly prismaService: PrismaService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async gather7Days() { public async gather7Days() {
const isDataGatheringNeeded = await this.isDataGatheringNeeded(); const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.'); Logger.log('7d data gathering has been started.');
console.time('data-gathering-7d'); console.time('data-gathering-7d');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -56,39 +59,39 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
console.log('7d data gathering has been completed.'); Logger.log('7d data gathering has been completed.');
console.timeEnd('data-gathering-7d'); console.timeEnd('data-gathering-7d');
} }
} }
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.'); Logger.log('Max data gathering has been started.');
console.time('data-gathering-max'); console.time('data-gathering-max');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -100,29 +103,124 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
console.log('Max data gathering has been completed.'); Logger.log('Max data gathering has been completed.');
console.timeEnd('data-gathering-max'); console.timeEnd('data-gathering-max');
} }
} }
public async gatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
console.time('data-gathering-symbol');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
console.timeEnd('data-gathering-symbol');
}
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { date_symbol: { date, symbol } }
});
}
} catch (error) {
Logger.error(error);
} finally {
return undefined;
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
console.log('Profile data gathering has been started.'); Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile'); console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems; let dataGatheringItems = aDataGatheringItems;
@ -132,16 +230,25 @@ export class DataGatheringService {
} }
const currentData = await this.dataProviderService.get(dataGatheringItems); const currentData = await this.dataProviderService.get(dataGatheringItems);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, response] of Object.entries(currentData)) { for (const [symbol, response] of Object.entries(currentData)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ currentData[symbol] = await dataEnhancer.enhance({
response, response,
symbol symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
console.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
} }
} }
@ -183,18 +290,21 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
console.error(`${symbol}: ${error?.meta?.cause}`); Logger.error(`${symbol}: ${error?.meta?.cause}`);
} }
} }
console.log('Profile data gathering has been completed.'); Logger.log('Profile data gathering has been completed.');
console.timeEnd('data-gathering-profile'); console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false; let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
@ -250,8 +360,18 @@ export class DataGatheringService {
} }
} catch (error) { } catch (error) {
hasError = true; hasError = true;
console.error(error); Logger.error(error);
} }
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
);
}
symbolCounter += 1;
} }
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
@ -261,30 +381,25 @@ export class DataGatheringService {
} }
} }
public async getCustomSymbolsToGather( public async getDataGatheringProgress() {
startDate?: Date const isInProgress = await this.getIsInProgress();
): Promise<IDataGatheringItem[]> {
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => { if (isInProgress) {
return { return this.dataGatheringProgress;
dataSource: DataSource.GHOSTFOLIO, }
date: startDate,
symbol: scraperConfiguration.symbol return undefined;
};
});
} }
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
} }
public async getLastDataGathering() { public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({ const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
if (lastDataGathering?.value) { if (lastDataGathering?.value) {
@ -294,76 +409,7 @@ export class DataGatheringService {
return undefined; return undefined;
} }
public async reset() { public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
console.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
}
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
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[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: startDate
};
});
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
return [
...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather,
...symbolProfilesToGather
];
}
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = const startDate =
( (
await this.prismaService.order.findFirst({ await this.prismaService.order.findFirst({
@ -371,9 +417,6 @@ export class DataGatheringService {
}) })
)?.date ?? new Date(); )?.date ?? new Date();
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
const currencyPairsToGather = this.exchangeRateDataService const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
@ -394,20 +437,82 @@ export class DataGatheringService {
select: { date: true }, select: { date: true },
take: 1 take: 1
}, },
scraperConfiguration: true,
symbol: true symbol: true
} }
}) })
).map((item) => { ).map((symbolProfile) => {
return { return {
dataSource: item.dataSource, ...symbolProfile,
date: item.Order?.[0]?.date ?? startDate, date: symbolProfile.Order?.[0]?.date ?? startDate
symbol: item.symbol
}; };
}); });
return [ return [
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
...customSymbolsToGather, ...currencyPairsToGather,
...symbolProfilesToGather
];
}
public async reset() {
Logger.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
}
});
}
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[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: startDate
};
});
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather, ...currencyPairsToGather,
...symbolProfilesToGather ...symbolProfilesToGather
]; ];
@ -436,7 +541,7 @@ export class DataGatheringService {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
const diffInHours = differenceInHours(new Date(), lastDataGathering); const diffInHours = differenceInHours(new Date(), lastDataGathering);

View File

@ -2,7 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
@ -78,7 +78,7 @@ export class AlphaVantageService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error, symbol); Logger.error(error, symbol);
return {}; return {};
} }

View File

@ -70,4 +70,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return Promise.resolve(response); return Promise.resolve(response);
} }
public getName() {
return 'TRACKINSIGHT';
}
} }

View File

@ -4,13 +4,19 @@ import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provi
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';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
@Module({ @Module({
imports: [ConfigurationModule, CryptocurrencyModule, PrismaModule], imports: [
ConfigurationModule,
CryptocurrencyModule,
PrismaModule,
SymbolProfileModule
],
providers: [ providers: [
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,

View File

@ -9,9 +9,9 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@Injectable() @Injectable()
@ -62,7 +62,7 @@ export class DataProviderService {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
if (isEmpty(aItems)) { if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
return response; return response;
} }
@ -96,7 +96,7 @@ export class DataProviderService {
ORDER BY date;`; ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
@ -109,7 +109,7 @@ export class DataProviderService {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} finally { } finally {
return response; return response;
} }

View File

@ -1,31 +1,33 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
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 { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getYesterday,
isGhostfolioScraperApiSymbol isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; 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 { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface'; import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { ScraperConfig } from './interfaces/scraper-config.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;
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol); return isGhostfolioScraperApiSymbol(symbol);
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbol = aSymbols[0]; const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol); [symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({ const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: { orderBy: {
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return { return {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: scraperConfig?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
} }
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
where: {
dataSource: DataSource.GHOSTFOLIO
}
});
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbols: string[],
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbol = aSymbols[0]; const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
const scraperConfiguration = await this.getScraperConfigurationBySymbol( [symbol]
symbol
); );
const scraperConfiguration = symbolProfile?.scraperConfiguration;
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {}); const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
@ -122,28 +106,12 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
}; };
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
} }
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const { value: scraperConfigString } =
await this.prismaService.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
return [];
}
public getName(): DataSource { public getName(): DataSource {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
@ -162,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return undefined; return undefined;
} }
} }
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
}
} }

View File

@ -1,6 +0,0 @@
export interface ScraperConfig {
currency: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -0,0 +1,4 @@
export interface ScraperConfiguration {
selector: string;
url: string;
}

View File

@ -8,4 +8,6 @@ export interface DataEnhancerInterface {
response: IDataProviderResponse; response: IDataProviderResponse;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse>; }): Promise<IDataProviderResponse>;
getName(): string;
} }

View File

@ -2,14 +2,9 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
DATE_FORMAT,
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
@ -31,10 +26,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return ( return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
} }
public async get( public async get(
@ -61,7 +53,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
@ -166,7 +158,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get(); const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; return undefined;
} }

View File

@ -0,0 +1,60 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
jest.mock(
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
() => {
return {
CryptocurrencyService: jest.fn().mockImplementation(() => {
return {
isCryptocurrency: (symbol: string) => {
switch (symbol) {
case 'BTCUSD':
return true;
case 'DOGEUSD':
return true;
default:
return false;
}
}
};
})
};
}
);
describe('YahooFinanceService', () => {
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
});
it('convertFromYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
).toEqual('BRK-B');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
).toEqual('BTCUSD');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
).toEqual('EURUSD');
});
it('convertToYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
).toEqual('BTC-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
).toEqual('DOGE-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
).toEqual('USDCHF=X');
});
});

View File

@ -1,14 +1,14 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { format } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { import {
@ -35,6 +35,44 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
} }
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`),
baseCurrency
);
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`),
`-${baseCurrency}`
);
}
}
return aSymbol;
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -69,7 +107,7 @@ export class YahooFinanceService implements DataProviderInterface {
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || value.price?.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCrypto(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? MarketState.open
: MarketState.closed, : MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0, marketPrice: value.price?.regularMarketPrice || 0,
@ -117,7 +155,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return {}; return {};
} }
@ -135,6 +173,10 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooFinanceSymbol(symbol); return this.convertToYahooFinanceSymbol(symbol);
}); });
@ -169,7 +211,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return {}; return {};
} }
@ -197,17 +239,21 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType }) => { .filter(({ quoteType, symbol }) => {
return ( return (
quoteType === 'CRYPTOCURRENCY' || (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) ||
quoteType === 'EQUITY' || quoteType === 'EQUITY' ||
quoteType === 'ETF' quoteType === 'ETF'
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
return symbol.includes('USD'); // Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency);
} }
return true; return true;
@ -231,43 +277,6 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
* SOL1USD -> SOL1-USD
*/
private convertToYahooFinanceSymbol(aSymbol: string) {
if (
(aSymbol.includes('CHF') ||
aSymbol.includes('EUR') ||
aSymbol.includes('USD')) &&
aSymbol.length >= 6
) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCrypto(aSymbol) ||
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace('USD', '-USD');
}
}
return aSymbol;
}
private parseAssetClass(aPrice: IYahooFinancePrice): { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;

View File

@ -3,9 +3,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule], imports: [DataProviderModule, PrismaModule, PropertyModule],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -1,13 +1,13 @@
import { baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash'; import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
@ -17,7 +17,8 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.initialize(); this.initialize();
} }
@ -40,7 +41,10 @@ export class ExchangeRateDataService {
currency2, currency2,
dataSource dataSource
} of this.prepareCurrencyPairs(this.currencies)) { } of this.prepareCurrencyPairs(this.currencies)) {
this.addCurrencyPairs({ currency1, currency2, dataSource }); this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
} }
await this.loadCurrencies(); await this.loadCurrencies();
@ -86,7 +90,7 @@ export class ExchangeRateDataService {
}; };
}); });
this.currencyPairs.forEach(({ symbol }) => { Object.keys(resultExtended).forEach((symbol) => {
const [currency1, currency2] = symbol.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT); const date = format(getYesterday(), DATE_FORMAT);
@ -140,33 +144,14 @@ export class ExchangeRateDataService {
} }
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
console.error( Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}` `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
); );
return aValue; return aValue;
} }
private addCurrencyPairs({
currency1,
currency2,
dataSource
}: {
currency1: string;
currency2: string;
dataSource: DataSource;
}) {
this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
this.currencyPairs.push({
dataSource,
symbol: `${currency2}${currency1}`
});
}
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
const currencies: string[] = []; let currencies: string[] = [];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
@ -198,6 +183,14 @@ export class ExchangeRateDataService {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);
}); });
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies?.length > 0) {
currencies = currencies.concat(customCurrencies);
}
return uniq(currencies).sort(); return uniq(currencies).sort();
} }

View File

@ -9,13 +9,16 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean; ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number; PORT: number;
RAKUTEN_RAPID_API_KEY: string; RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;

View File

@ -3,11 +3,10 @@ import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
SymbolProfile SymbolProfile,
Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import { OrderType } from '../../models/order-type';
export const MarketState = { export const MarketState = {
closed: 'closed', closed: 'closed',
delayed: 'delayed', delayed: 'delayed',
@ -24,7 +23,7 @@ export interface IOrder {
quantity: number; quantity: number;
symbol: string; symbol: string;
symbolProfile: SymbolProfile; symbolProfile: SymbolProfile;
type: OrderType; type: TypeOfOrder;
unitPrice: number; unitPrice: number;
} }

View File

@ -1,3 +1,4 @@
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
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 { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
@ -5,13 +6,15 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface EnhancedSymbolProfile { export interface EnhancedSymbolProfile {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
countries: Country[];
createdAt: Date; createdAt: Date;
currency: string | null; currency: string | null;
dataSource: DataSource; dataSource: DataSource;
id: string; id: string;
name: string | null; name: string | null;
updatedAt: Date; scraperConfiguration?: ScraperConfiguration | null;
symbol: string;
countries: Country[];
sectors: Sector[]; sectors: Sector[];
symbol: string;
symbolMapping?: { [key: string]: string };
updatedAt: Date;
} }

View File

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

View File

@ -1,9 +1,8 @@
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 } from '@prisma/client'; import { MarketData, Prisma } from '@prisma/client';
import { DateQuery } from './interfaces/date-query.interface';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
@ -48,4 +47,34 @@ export class MarketDataService {
} }
}); });
} }
public async marketDataItems(params: {
skip?: number;
take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.marketData.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async updateMarketData(params: {
data: Prisma.MarketDataUpdateInput;
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.update({
data,
where
});
}
} }

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class PropertyDto {
@IsString()
value: string;
}

View File

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

View File

@ -0,0 +1,49 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PropertyService {
public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: string }) {
return this.prismaService.property.delete({
where: { key }
});
}
public async get() {
const response: {
[key: string]: boolean | object | string | string[];
} = {
[PROPERTY_CURRENCIES]: []
};
const properties = await this.prismaService.property.findMany();
for (const property of properties) {
let value = property.value;
try {
value = JSON.parse(property.value);
} catch {}
response[property.key] = value;
}
return response;
}
public async getByKey(aKey: string) {
const properties = await this.get();
return properties?.[aKey];
}
public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({
create: { key, value },
update: { value },
where: { key }
});
}
}

View File

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

View File

@ -7,6 +7,8 @@ import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client'; import { 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';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
@ -29,7 +31,9 @@ export class SymbolProfileService {
return symbolProfiles.map((symbolProfile) => ({ return symbolProfiles.map((symbolProfile) => ({
...symbolProfile, ...symbolProfile,
countries: this.getCountries(symbolProfile), countries: this.getCountries(symbolProfile),
sectors: this.getSectors(symbolProfile) scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
})); }));
} }
@ -49,6 +53,22 @@ export class SymbolProfileService {
); );
} }
private getScraperConfiguration(
symbolProfile: SymbolProfile
): ScraperConfiguration {
const scraperConfiguration =
symbolProfile.scraperConfiguration as Prisma.JsonObject;
if (scraperConfiguration) {
return {
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};
}
return null;
}
private getSectors(symbolProfile: SymbolProfile): Sector[] { private getSectors(symbolProfile: SymbolProfile): Sector[] {
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map( return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
(sector) => { (sector) => {
@ -61,4 +81,12 @@ export class SymbolProfileService {
} }
); );
} }
private getSymbolMapping(symbolProfile: SymbolProfile) {
return (
(symbolProfile['symbolMapping'] as {
[key: string]: string;
}) ?? {}
);
}
} }

View File

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

View File

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

View File

@ -14,5 +14,8 @@ module.exports = {
'jest-preset-angular/build/serializers/ng-snapshot', 'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment' 'jest-preset-angular/build/serializers/html-comment'
], ],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' } transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
}; };

View File

@ -1,6 +1,6 @@
<header> <header>
<gf-header <gf-header
class="position-fixed px-2 w-100" class="position-fixed w-100"
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[info]="info" [info]="info"
[user]="user" [user]="user"
@ -9,18 +9,27 @@
</header> </header>
<main role="main"> <main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container"> <div
*ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container"
>
<div class="row"> <div class="row">
<div class="col-md-8 offset-md-2 text-center"> <div class="col-md-8 offset-md-2 text-center">
<a class="text-center" [routerLink]="['/']"> <a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
<div <div
class="create-account-box d-inline-block px-3 py-2" class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span i18n>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<a class="ml-2" href="#" i18n>Create Account</a> <a class="ml-2" href="#" i18n>Create Account</a>
</div></a </div></a
> >
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
class="d-inline-block info-message px-3 py-2"
>
{{ info.systemMessage }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,14 +8,13 @@
min-height: 100vh; min-height: 100vh;
padding-top: 5rem; padding-top: 5rem;
.create-account-container { .info-message-container {
height: 3.5rem; height: 3.5rem;
margin-top: -0.5rem; margin-top: -0.5rem;
.create-account-box { .info-message {
background-color: rgba(0, 0, 0, $alpha-hover); background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem; border-radius: 2rem;
cursor: pointer;
font-size: 80%; font-size: 80%;
a { a {

View File

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

View File

@ -15,6 +15,24 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
@ -37,6 +55,11 @@
<span>{{ element.Platform?.name }}</span> <span>{{ element.Platform?.name }}</span>
</div> </div>
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
@ -45,7 +68,12 @@
<span class="d-none d-sm-block" i18n>Transactions</span> <span class="d-none d-sm-block" i18n>Transactions</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.transactionCount }} <ng-container *ngIf="element.accountType === 'SECURITIES'">{{
element.transactionCount
}}</ng-container>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }}
</td> </td>
</ng-container> </ng-container>
@ -56,9 +84,39 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[currency]="element.currency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="element.balance" [value]="element.convertedBalance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Value
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.value"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>
@ -88,10 +146,16 @@
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading }"
></tr>
</table> </table>
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -10,6 +10,16 @@
} }
.mat-table { .mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {
@ -21,7 +31,14 @@
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-form-field { .mat-table {
color: rgba(var(--light-primary-text)); td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
} }
} }

View File

@ -24,6 +24,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() locale: string;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ -41,7 +44,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions', 'balance']; this.displayedColumns = [
'account',
'currency',
'platform',
'transactions',
'balance',
'value'
];
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');

View File

@ -0,0 +1,37 @@
<div>
<gf-line-chart
class="mb-4"
[historicalDataItems]="historicalDataItems"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
<div
*ngFor="let dayItem of days; let i = index"
class="day"
[ngClass]="{
'cursor-pointer valid': isDateOfInterest(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
),
available:
marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? '0' + (i + 1) : i + 1
]?.day ===
i + 1
}"
[title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date: defaultDateFormat) ?? ''
"
(click)="
onOpenMarketDataDetail({
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
yearMonth: itemByMonth.key
})
"
></div>
</div>
</div>
</div>

View File

@ -0,0 +1,29 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
font-size: 0.9rem;
.date {
font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums;
}
.day {
height: 0.5rem;
margin-right: 0.25rem;
width: 0.5rem;
&:hover {
opacity: 0.8;
}
&.valid {
background-color: var(--danger);
}
&.available {
background-color: var(--success);
}
}
}

View File

@ -0,0 +1,117 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data-detail',
styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource;
@Input() marketData: MarketData[];
@Input() symbol: string;
@Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public deviceType: string;
public historicalDataItems: LineChartItem[];
public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
} = {};
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnInit() {}
public ngOnChanges() {
this.historicalDataItems = this.marketData.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value: marketDataItem.marketPrice
};
});
this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
...marketDataItem,
day: currentDay
};
}
}
public isDateOfInterest(aDateString: string) {
// Date is valid and in the past
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isBefore(date, new Date());
}
public onOpenMarketDataDetail({
day,
yearMonth
}: {
day: string;
yearMonth: string;
}) {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
marketPrice,
dataSource: this.dataSource,
date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => {
this.marketDataChanged.next(withRefresh);
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

View File

@ -0,0 +1,8 @@
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
dataSource: DataSource;
date: Date;
marketPrice: number;
symbol: string;
}

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