Compare commits
149 Commits
Author | SHA1 | Date | |
---|---|---|---|
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 | |||
8b9379f5ce | |||
0806d0dc92 | |||
e518bc3779 | |||
eff807dd9a | |||
155bf67f60 | |||
9aefe3747e | |||
0878febded | |||
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c | |||
78e0fdb0ca | |||
606350b2ff | |||
d09cad4e05 | |||
069660afe4 | |||
4d9a223491 | |||
aed8f5cf04 | |||
1beb4de62f | |||
9bc3505ded | |||
3e82de6b21 | |||
563f354e7e | |||
e96e6c717c | |||
961774ce9f | |||
49f46e1a1e | |||
050c0a4da7 | |||
4908e6d35d | |||
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@
|
|||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
/.angular/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
31
.travis.yml
31
.travis.yml
@ -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
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
]
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
395
CHANGELOG.md
395
CHANGELOG.md
@ -5,7 +5,386 @@ 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).
|
||||||
|
|
||||||
## Unreleased
|
## 1.100.0 - 05.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the routing of the create activity dialog
|
||||||
|
- Fixed the link color in the blog posts
|
||||||
|
|
||||||
|
## 1.99.0 - 01.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the profile data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||||
|
- Restructured the about page
|
||||||
|
- Start refactoring _transactions_ to _activities_
|
||||||
|
- Refactored the demo user id
|
||||||
|
- Upgraded `angular` from version `13.0.2` to `13.1.1`
|
||||||
|
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
|
||||||
|
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the data provider warning while loading
|
||||||
|
- Fixed an exception with the market state caused by a failed data provider request
|
||||||
|
- Fixed an exception in the portfolio position endpoint
|
||||||
|
- Fixed the reload of the position detail dialog (with query parameters)
|
||||||
|
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
|
||||||
|
|
||||||
|
## 1.98.0 - 29.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the holdings tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the statistics section on the about page (users in Slack community)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||||
|
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||||
|
|
||||||
|
## 1.97.0 - 28.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the transactions to the position detail dialog
|
||||||
|
- Added support for dividend
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.96.0 - 27.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made the data provider warning more discreet
|
||||||
|
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||||
|
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||||
|
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the file type detection in the import functionality for transactions
|
||||||
|
|
||||||
|
## 1.95.0 - 26.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a warning to the log if the data gathering fails
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Filtered potential `null` currencies
|
||||||
|
- Improved the 7d data gathering optimization for currencies
|
||||||
|
|
||||||
|
## 1.94.0 - 25.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
|
||||||
|
- Made the import functionality for transactions by `csv` files more flexible
|
||||||
|
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
|
||||||
|
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
|
||||||
|
|
||||||
|
## 1.93.0 - 21.12.2021
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@ -52,7 +431,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
|
||||||
|
|
||||||
@ -150,7 +529,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
|
||||||
|
|
||||||
@ -165,7 +544,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
|
||||||
|
|
||||||
@ -186,7 +565,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
|
||||||
|
|
||||||
@ -308,7 +687,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
|
||||||
|
|
||||||
@ -361,7 +740,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
|
||||||
|
|
||||||
@ -421,7 +800,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
|
||||||
|
|
||||||
|
@ -12,7 +12,8 @@ COPY ./package.json package.json
|
|||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN yarn
|
RUN apk add --no-cache python3 g++ make openssl
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
52
README.md
52
README.md
@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -81,27 +81,43 @@ 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).
|
||||||
|
|
||||||
## Run with Docker
|
## Run with Docker (self-hosting)
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
### Setup Docker Image
|
### a. Run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker image:
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml build
|
docker-compose -f docker/docker-compose.yml up
|
||||||
docker-compose -f docker/docker-compose-build-local.yml up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Database
|
#### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn setup:database
|
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
### Fetch Historical Data
|
||||||
@ -112,6 +128,20 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
|
### Finalization
|
||||||
|
|
||||||
|
1. Create a new user via _Get Started_
|
||||||
|
1. Assign the role `ADMIN` to this user (directly in the database)
|
||||||
|
1. Delete the original _Admin_ (directly in the database)
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
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
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@ -123,10 +153,8 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `cd docker`
|
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
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. 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
|
||||||
|
37
angular.json
37
angular.json
@ -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",
|
||||||
@ -69,7 +52,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -201,7 +185,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
@ -221,7 +206,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
@ -242,7 +229,8 @@
|
|||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -300,7 +288,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
@ -326,7 +315,9 @@
|
|||||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["ui"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
@ -66,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),
|
||||||
@ -86,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),
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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),
|
||||||
|
@ -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 {}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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,177 @@ export class AdminController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileDataForSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@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: { ...data, dataSource },
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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],
|
||||||
|
@ -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']> {
|
||||||
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateMarketDataDto {
|
||||||
|
@IsNumber()
|
||||||
|
marketPrice: number;
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
4
apps/api/src/app/cache/cache.module.ts
vendored
4
apps/api/src/app/cache/cache.module.ts
vendored
@ -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: [
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 {}
|
|
@ -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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export interface Data {
|
|
||||||
currency: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
@ -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}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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: [
|
||||||
|
@ -1,19 +1,29 @@
|
|||||||
|
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 {
|
||||||
|
DEMO_USER_ID,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
|
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 DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -21,15 +31,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 +55,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 +75,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 +139,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,15 +160,43 @@ 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 async countSlackCommunityUsers() {
|
||||||
|
return (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: DEMO_USER_ID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,17 +212,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 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();
|
||||||
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
|
|
||||||
return {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers
|
gitHubStargazers,
|
||||||
|
newUsers30d,
|
||||||
|
slackCommunityUsers
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 +254,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) {
|
||||||
|
@ -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),
|
||||||
@ -73,28 +66,21 @@ export class OrderController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let orders = await this.orderService.orders({
|
let orders = await this.orderService.getOrders({
|
||||||
include: {
|
includeDrafts: true,
|
||||||
Account: {
|
userId: impersonationUserId || this.request.user.id
|
||||||
include: {
|
|
||||||
Platform: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SymbolProfile: {
|
|
||||||
select: {
|
|
||||||
name: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
where: { userId: impersonationUserId || this.request.user.id }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
orders = nullifyValuesInObjects(orders, [
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return orders;
|
return orders;
|
||||||
@ -115,10 +101,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 +144,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),
|
||||||
|
@ -3,7 +3,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -28,7 +29,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 +46,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 +69,8 @@ export class OrderService {
|
|||||||
return this.prismaService.order.create({
|
return this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft
|
isDraft,
|
||||||
|
symbol
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -78,11 +83,13 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders({
|
public async getOrders({
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
types,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
types?: TypeOfOrder[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
@ -91,15 +98,39 @@ export class OrderService {
|
|||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orders({
|
if (types) {
|
||||||
where,
|
where.OR = types.map((type) => {
|
||||||
include: {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
type: {
|
||||||
Account: true,
|
equals: type
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
}
|
||||||
SymbolProfile: true
|
};
|
||||||
},
|
});
|
||||||
orderBy: { date: 'asc' }
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
await this.orders({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: {
|
||||||
|
include: {
|
||||||
|
Platform: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
})
|
||||||
|
).map((order) => {
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value: new Big(order.quantity)
|
||||||
|
.mul(order.unitPrice)
|
||||||
|
.plus(order.fee)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
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;
|
||||||
@ -12,9 +17,17 @@ export interface PortfolioPositionDetail {
|
|||||||
name: string;
|
name: string;
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalDataContainer {
|
||||||
|
isAllTimeHigh: boolean;
|
||||||
|
isAllTimeLow: boolean;
|
||||||
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
export interface HistoricalDataItem {
|
||||||
|
@ -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[];
|
||||||
|
}
|
@ -1,51 +0,0 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { MarketData } from '@prisma/client';
|
|
||||||
|
|
||||||
import { DateQuery } from './interfaces/date-query.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MarketDataService {
|
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
|
||||||
|
|
||||||
public async get({
|
|
||||||
date,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<MarketData> {
|
|
||||||
return await this.prismaService.marketData.findFirst({
|
|
||||||
where: {
|
|
||||||
symbol,
|
|
||||||
date: resetHours(date)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRange({
|
|
||||||
dateQuery,
|
|
||||||
symbols
|
|
||||||
}: {
|
|
||||||
dateQuery: DateQuery;
|
|
||||||
symbols: string[];
|
|
||||||
}): Promise<MarketData[]> {
|
|
||||||
return await this.prismaService.marketData.findMany({
|
|
||||||
orderBy: [
|
|
||||||
{
|
|
||||||
date: 'asc'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: 'asc'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
where: {
|
|
||||||
date: dateQuery,
|
|
||||||
symbol: {
|
|
||||||
in: symbols
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -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';
|
||||||
@ -23,6 +22,20 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
switch (symbol) {
|
switch (symbol) {
|
||||||
case 'AMZN':
|
case 'AMZN':
|
||||||
return { marketPrice: 2021.99 };
|
return { marketPrice: 2021.99 };
|
||||||
|
case 'BALN.SW':
|
||||||
|
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||||
|
return { marketPrice: 146 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-22'), date)) {
|
||||||
|
return { marketPrice: 142.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-26'), date)) {
|
||||||
|
return { marketPrice: 139.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-30'), date)) {
|
||||||
|
return { marketPrice: 136.6 };
|
||||||
|
} else if (isSameDay(parseDate('2021-12-18'), date)) {
|
||||||
|
return { marketPrice: 143.9 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
case 'MFA':
|
case 'MFA':
|
||||||
if (isSameDay(parseDate('2010-12-31'), date)) {
|
if (isSameDay(parseDate('2010-12-31'), date)) {
|
||||||
return { marketPrice: 1 };
|
return { marketPrice: 1 };
|
||||||
@ -46,10 +59,10 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
case 'TSLA':
|
case 'TSLA':
|
||||||
if (isSameDay(parseDate('2021-07-26'), date)) {
|
if (isSameDay(parseDate('2021-01-02'), date)) {
|
||||||
return { marketPrice: 657.62 };
|
|
||||||
} else if (isSameDay(parseDate('2021-01-02'), date)) {
|
|
||||||
return { marketPrice: 666.66 };
|
return { marketPrice: 666.66 };
|
||||||
|
} else if (isSameDay(parseDate('2021-07-26'), date)) {
|
||||||
|
return { marketPrice: 657.62 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
@ -63,6 +76,7 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
)
|
)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
}
|
}
|
||||||
@ -73,20 +87,10 @@ jest.mock('./current-rate.service', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
getValue: ({
|
getValue: ({ date, symbol }: GetValueParams) => {
|
||||||
currency,
|
|
||||||
date,
|
|
||||||
symbol,
|
|
||||||
userCurrency
|
|
||||||
}: GetValueParams) => {
|
|
||||||
return Promise.resolve(mockGetValue(symbol, date));
|
return Promise.resolve(mockGetValue(symbol, date));
|
||||||
},
|
},
|
||||||
getValues: ({
|
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||||
currencies,
|
|
||||||
dateQuery,
|
|
||||||
dataGatheringItems,
|
|
||||||
userCurrency
|
|
||||||
}: GetValuesParams) => {
|
|
||||||
const result = [];
|
const result = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
@ -155,7 +159,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 +170,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 +181,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 +252,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 +263,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 +274,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 +364,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 +466,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 +621,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 +632,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,
|
||||||
@ -1487,6 +1491,126 @@ describe('PortfolioCalculator', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with BALN.SW', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
|
currentRateService,
|
||||||
|
'CHF'
|
||||||
|
);
|
||||||
|
|
||||||
|
// date,type,ticker,currency,units,price,fee
|
||||||
|
portfolioCalculator.setTransactionPoints([
|
||||||
|
// 12.11.2021,BUY,BALN.SW,CHF,2.00,146.00,1.65
|
||||||
|
{
|
||||||
|
date: '2021-11-12',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
quantity: new Big('2'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
investment: new Big('292'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
firstBuyDate: '2021-11-12',
|
||||||
|
fee: new Big('1.65'),
|
||||||
|
transactionCount: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||||
|
// End Value: 142.9 * 2 = 285.8
|
||||||
|
// Initial Value: 292 (Investment)
|
||||||
|
// Cash Flow: 0
|
||||||
|
// HWR_n0: (285.8 - 292) / 292 = -0.021232877
|
||||||
|
|
||||||
|
// 22.11.2021,BUY,BALN.SW,CHF,7.00,142.90,5.75
|
||||||
|
{
|
||||||
|
date: '2021-11-22',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
quantity: new Big('9'), // 7 + 2
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
investment: new Big('1292.3'), // 142.9 * 7 + 146 * 2
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
firstBuyDate: '2021-11-12',
|
||||||
|
fee: new Big('7.4'), // 1.65 + 5.75
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||||
|
// End Value: 139.9 * 9 = 1259.1
|
||||||
|
// Initial Value: 285.8 (End Value n0)
|
||||||
|
// Cash Flow: 1000.3
|
||||||
|
// Initial Value + Cash Flow: 285.8 + 1000.3 = 1286.1
|
||||||
|
// HWR_n1: (1259.1 - 1286.1) / 1286.1 = -0.020993702
|
||||||
|
|
||||||
|
// 26.11.2021,BUY,BALN.SW,CHF,3.00,139.90,2.40
|
||||||
|
{
|
||||||
|
date: '2021-11-26',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
quantity: new Big('12'), // 3 + 7 + 2
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
investment: new Big('1712'), // 139.9 * 3 + 142.9 * 7 + 146 * 2
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
firstBuyDate: '2021-11-12',
|
||||||
|
fee: new Big('9.8'), // 2.40 + 1.65 + 5.75
|
||||||
|
transactionCount: 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||||
|
// End Value: 136.6 * 12 = 1639.2
|
||||||
|
// Initial Value: 1259.1 (End Value n1)
|
||||||
|
// Cash Flow: 139.9 * 3 = 419.7
|
||||||
|
// Initial Value + Cash Flow: 1259.1 + 419.7 = 1678.8
|
||||||
|
// HWR_n2: (1639.2 - 1678.8) / 1678.8 = -0.023588277
|
||||||
|
|
||||||
|
// 30.11.2021,BUY,BALN.SW,CHF,2.00,136.60,1.55
|
||||||
|
{
|
||||||
|
date: '2021-11-30',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
quantity: new Big('14'), // 2 + 3 + 7 + 2
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
investment: new Big('1985.2'), // 136.6 * 2 + 139.9 * 3 + 142.9 * 7 + 146 * 2
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
firstBuyDate: '2021-11-12',
|
||||||
|
fee: new Big('11.35'), // 1.55 + 2.40 + 1.65 + 5.75
|
||||||
|
transactionCount: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||||
|
// End Value: 143.9 * 14 = 2014.6
|
||||||
|
// Initial Value: 1639.2 (End Value n2)
|
||||||
|
// Cash Flow: 136.6 * 2 = 273.2
|
||||||
|
// Initial Value + Cash Flow: 1639.2 + 273.2 = 1912.4
|
||||||
|
// HWR_n3: (2014.6 - 1912.4) / 1912.4 = 0.053440703
|
||||||
|
]);
|
||||||
|
|
||||||
|
// HWR_total = 1 - (HWR_n0 + 1) * (HWR_n1 + 1) * (HWR_n2 + 1) * (HWR_n3 + 1)
|
||||||
|
// HWR_total = 1 - (-0.021232877 + 1) * (-0.020993702 + 1) * (-0.023588277 + 1) * (0.053440703 + 1) = 0.014383561
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => new Date(Date.UTC(2021, 11, 18)).getTime()); // 2021-12-18
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2021-11-01')
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toBeDefined();
|
||||||
|
expect(currentPositions.grossPerformance).toEqual(new Big('29.4'));
|
||||||
|
expect(currentPositions.netPerformance).toEqual(new Big('18.05'));
|
||||||
|
expect(currentPositions.grossPerformancePercentage).toEqual(
|
||||||
|
new Big('-0.01438356164383561644')
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculate timeline', () => {
|
describe('calculate timeline', () => {
|
||||||
@ -1502,11 +1626,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 +1746,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 +1789,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 +2007,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 +2028,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 +2114,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 +2423,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 +2515,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 +2526,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 +2537,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 +2551,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 +2562,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 +2573,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 +2584,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 +2595,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,
|
||||||
|
@ -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:
|
||||||
|
@ -8,13 +8,14 @@ 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,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -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';
|
||||||
|
|
||||||
@ -50,66 +48,28 @@ export class PortfolioController {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('investments')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async findAll(
|
|
||||||
@Headers('impersonation-id') impersonationId,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<InvestmentItem[]> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
|
||||||
return <any>res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
|
||||||
impersonationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
const maxInvestment = investments.reduce(
|
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
investments = investments.map((item) => ({
|
|
||||||
date: item.date,
|
|
||||||
investment: item.investment / maxInvestment
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <any>res.json(investments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<PortfolioChart> {
|
||||||
let chartData = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
let hasNullValue = false;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||||
hasNullValue = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNullValue) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -130,13 +90,18 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(chartData);
|
return <any>res.json({
|
||||||
|
hasError,
|
||||||
|
chart: chartData,
|
||||||
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioDetails> {
|
): Promise<PortfolioDetails> {
|
||||||
@ -148,6 +113,8 @@ export class PortfolioController {
|
|||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioService.getDetails(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
@ -156,7 +123,7 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -194,43 +161,74 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({ accounts, holdings });
|
return <any>res.json({ accounts, hasError, holdings });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('investments')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getInvestments(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioInvestments> {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
|
return <any>res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxInvestment = investments.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPerformance> {
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
if (performanceInformation?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let performance = performanceInformation.performance;
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performance = nullifyValuesInObject(performance, [
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
'currentGrossPerformance',
|
performanceInformation.performance,
|
||||||
'currentValue'
|
['currentGrossPerformance', 'currentValue']
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(performance);
|
return <any>res.json(performanceInformation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
@ -239,10 +237,6 @@ export class PortfolioController {
|
|||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -336,6 +330,7 @@ export class PortfolioController {
|
|||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
'fees',
|
'fees',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
@ -349,7 +344,7 @@ export class PortfolioController {
|
|||||||
@Get('position/:symbol')
|
@Get('position/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
let position = await this.portfolioService.getPosition(
|
||||||
@ -366,7 +361,9 @@ export class PortfolioController {
|
|||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
'quantity'
|
'orders',
|
||||||
|
'quantity',
|
||||||
|
'value'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,7 +379,7 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
if (
|
if (
|
||||||
|
@ -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 {}
|
||||||
|
@ -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, sortBy } 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[]> {
|
||||||
@ -100,18 +150,39 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioCalculator.getInvestments().map((item) => {
|
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add investment of today
|
||||||
|
const investmentOfToday = investments.filter((investment) => {
|
||||||
|
return investment.date === format(new Date(), DATE_FORMAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (investmentOfToday.length <= 0) {
|
||||||
|
const pastInvestments = investments.filter((investment) => {
|
||||||
|
return isBefore(parseDate(investment.date), new Date());
|
||||||
|
});
|
||||||
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
|
investments.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
investment: lastInvestment?.investment ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
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 +193,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 +216,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 +363,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,
|
||||||
@ -293,26 +409,34 @@ export class PortfolioService {
|
|||||||
name: undefined,
|
name: undefined,
|
||||||
netPerformance: undefined,
|
netPerformance: undefined,
|
||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
|
orders: [],
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
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 ?? '';
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
currency: order.currency,
|
.filter((order) => {
|
||||||
dataSource: order.dataSource,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
date: format(order.date, DATE_FORMAT),
|
})
|
||||||
fee: new Big(order.fee),
|
.map((order) => ({
|
||||||
name: order.SymbolProfile?.name,
|
currency: order.currency,
|
||||||
quantity: new Big(order.quantity),
|
dataSource: order.dataSource,
|
||||||
symbol: order.symbol,
|
date: format(order.date, DATE_FORMAT),
|
||||||
type: <OrderType>order.type,
|
fee: new Big(order.fee),
|
||||||
unitPrice: new Big(order.unitPrice)
|
name: order.SymbolProfile?.name,
|
||||||
}));
|
quantity: new Big(order.quantity),
|
||||||
|
symbol: order.symbol,
|
||||||
|
type: order.type,
|
||||||
|
unitPrice: new Big(order.unitPrice)
|
||||||
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -344,17 +468,17 @@ export class PortfolioService {
|
|||||||
// Convert investment, gross and net performance to currency of user
|
// Convert investment, gross and net performance to currency of user
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const investment = this.exchangeRateDataService.toCurrency(
|
const investment = this.exchangeRateDataService.toCurrency(
|
||||||
position.investment.toNumber(),
|
position.investment?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.grossPerformance.toNumber(),
|
position.grossPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.netPerformance.toNumber(),
|
position.netPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
@ -412,6 +536,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
currency,
|
currency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
@ -421,13 +547,19 @@ export class PortfolioService {
|
|||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
|
orders,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||||
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,10 +599,13 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
|
orders,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
currency: currentData[aSymbol]?.currency,
|
currency: currentData[aSymbol]?.currency,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -482,7 +617,8 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
value: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -547,7 +683,9 @@ export class PortfolioService {
|
|||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState: dataProviderResponses[position.symbol].marketState,
|
marketState:
|
||||||
|
dataProviderResponses[position.symbol]?.marketState ??
|
||||||
|
MarketState.delayed,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage:
|
||||||
@ -613,27 +751,11 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent,
|
||||||
currentNetPerformance,
|
currentNetPerformance,
|
||||||
currentNetPerformancePercent,
|
currentNetPerformancePercent,
|
||||||
currentValue: currentValue
|
currentValue
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
|
||||||
return orders
|
|
||||||
.filter((order) => {
|
|
||||||
// Filter out all orders before given date
|
|
||||||
return isBefore(date, new Date(order.date));
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.currency,
|
|
||||||
this.request.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
@ -663,7 +785,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,
|
||||||
@ -714,7 +836,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees(orders)
|
this.getFees(orders).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency: currency }
|
{ baseCurrency: currency }
|
||||||
@ -733,12 +855,15 @@ export class PortfolioService {
|
|||||||
userId,
|
userId,
|
||||||
currency
|
currency
|
||||||
);
|
);
|
||||||
const orders = await this.orderService.getOrders({ userId });
|
const orders = await this.orderService.getOrders({
|
||||||
const fees = this.getFees(orders);
|
userId
|
||||||
|
});
|
||||||
|
const dividend = this.getDividend(orders).toNumber();
|
||||||
|
const fees = this.getFees(orders).toNumber();
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = orders[0]?.date;
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, currency, 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);
|
||||||
|
|
||||||
@ -748,49 +873,128 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
|
dividend,
|
||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
netWorth,
|
netWorth,
|
||||||
|
totalBuy,
|
||||||
|
totalSell,
|
||||||
cash: balance,
|
cash: balance,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
ordersCount: orders.length,
|
ordersCount: orders.filter((order) => {
|
||||||
totalBuy: totalBuy,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
totalSell: totalSell
|
}).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date and type dividend
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(order.date)) &&
|
||||||
|
order.type === TypeOfOrder.DIVIDEND
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date
|
||||||
|
return isBefore(date, new Date(order.date));
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
@ -821,7 +1025,11 @@ export class PortfolioService {
|
|||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
}> {
|
}> {
|
||||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
const orders = await this.orderService.getOrders({
|
||||||
|
includeDrafts,
|
||||||
|
userId,
|
||||||
|
types: ['BUY', 'SELL']
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
@ -842,7 +1050,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 +1071,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 +1086,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 +1115,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
|
||||||
};
|
};
|
||||||
|
@ -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')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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]
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } 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 = 30;
|
||||||
|
|
||||||
|
const marketData = await this.marketDataService.getRange({
|
||||||
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
|
symbols: [dataGatheringItem.symbol]
|
||||||
|
});
|
||||||
|
|
||||||
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
date: date.toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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],
|
||||||
|
@ -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({
|
||||||
|
@ -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();
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
export enum OrderType {
|
|
||||||
CorporateAction = 'CORPORATE_ACTION',
|
|
||||||
Bonus = 'BONUS',
|
|
||||||
Buy = 'BUY',
|
|
||||||
Dividend = 'DIVIDEND',
|
|
||||||
Sell = 'SELL',
|
|
||||||
Split = 'SPLIT'
|
|
||||||
}
|
|
@ -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) {
|
||||||
|
@ -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' }),
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"1INCH": "1inch",
|
"1INCH": "1inch",
|
||||||
|
"ALGO": "Algorand",
|
||||||
|
"ATOM": "Cosmos",
|
||||||
"AVAX": "Avalanche",
|
"AVAX": "Avalanche",
|
||||||
|
"DOT": "Polkadot",
|
||||||
"MATIC": "Polygon",
|
"MATIC": "Polygon",
|
||||||
"SHIB": "Shiba Inu"
|
"SHIB": "Shiba Inu",
|
||||||
|
"SOL": "Solana",
|
||||||
|
"UNI3": "Uniswap"
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
benchmarks,
|
PROPERTY_LAST_DATA_GATHERING,
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
PROPERTY_LOCKED_DATA_GATHERING
|
||||||
} 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 } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
@ -15,9 +16,7 @@ import {
|
|||||||
subDays
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { 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 +24,27 @@ import { PrismaService } from './prisma.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
|
private dataGatheringProgress: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
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 +56,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 +100,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 +227,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 +287,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 }],
|
||||||
@ -227,16 +334,25 @@ export class DataGatheringService {
|
|||||||
?.marketPrice;
|
?.marketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (lastMarketPrice) {
|
||||||
await this.prismaService.marketData.create({
|
try {
|
||||||
data: {
|
await this.prismaService.marketData.create({
|
||||||
dataSource,
|
data: {
|
||||||
symbol,
|
dataSource,
|
||||||
date: currentDate,
|
symbol,
|
||||||
marketPrice: lastMarketPrice
|
date: currentDate,
|
||||||
}
|
marketPrice: lastMarketPrice
|
||||||
});
|
}
|
||||||
} catch {}
|
});
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
Logger.warn(
|
||||||
|
`Failed to gather data for symbol ${symbol} at ${format(
|
||||||
|
currentDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Count month one up for iteration
|
// Count month one up for iteration
|
||||||
currentDate = new Date(
|
currentDate = new Date(
|
||||||
@ -250,8 +366,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 +387,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 +415,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 +423,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,49 +443,110 @@ 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 [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
...this.getBenchmarksToGather(startDate),
|
}
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
public async reset() {
|
||||||
...symbolProfilesToGather
|
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 async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
|
// Only consider symbols with incomplete market data for the last
|
||||||
|
// 7 days
|
||||||
|
const symbolsToGather = (
|
||||||
|
await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['symbol'],
|
||||||
|
where: {
|
||||||
|
date: { gt: startDate }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((group) => {
|
||||||
|
return group._count < 6;
|
||||||
|
})
|
||||||
|
.map((group) => {
|
||||||
|
return group.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return symbolsToGather.includes(symbol);
|
||||||
|
})
|
||||||
|
.map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return symbolsToGather.includes(symbol);
|
||||||
|
})
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
|
||||||
|
|
||||||
const distinctOrders = await this.prismaService.order.findMany({
|
const distinctOrders = await this.prismaService.order.findMany({
|
||||||
distinct: ['symbol'],
|
distinct: ['symbol'],
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: { dataSource: true, symbol: true }
|
select: { dataSource: true, symbol: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
return distinctOrders.filter((distinctOrder) => {
|
||||||
(distinctOrder) => {
|
return (
|
||||||
return (
|
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
private async isDataGatheringNeeded() {
|
||||||
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);
|
||||||
|
@ -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 {};
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,9 @@ const getJSON = bent('json');
|
|||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static countriesMapping = {
|
||||||
|
'Russian Federation': 'Russia'
|
||||||
|
};
|
||||||
private static sectorsMapping = {
|
private static sectorsMapping = {
|
||||||
'Consumer Discretionary': 'Consumer Cyclical',
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
'Consumer Defensive': 'Consumer Staples',
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
TrackinsightDataEnhancerService.countries
|
TrackinsightDataEnhancerService.countries
|
||||||
)) {
|
)) {
|
||||||
if (country.name === name) {
|
if (
|
||||||
|
country.name === name ||
|
||||||
|
country.name ===
|
||||||
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||||
|
) {
|
||||||
countryCode = key;
|
countryCode = key;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -70,4 +77,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
return Promise.resolve(response);
|
return Promise.resolve(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return 'TRACKINSIGHT';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export interface ScraperConfig {
|
|
||||||
currency: string;
|
|
||||||
selector: string;
|
|
||||||
symbol: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface ScraperConfiguration {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
}
|
@ -8,4 +8,6 @@ export interface DataEnhancerInterface {
|
|||||||
response: IDataProviderResponse;
|
response: IDataProviderResponse;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderResponse>;
|
}): Promise<IDataProviderResponse>;
|
||||||
|
|
||||||
|
getName(): string;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -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,39 +144,25 @@ 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({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((account) => {
|
).forEach((account) => {
|
||||||
currencies.push(account.currency);
|
currencies.push(account.currency);
|
||||||
@ -182,7 +172,12 @@ export class ExchangeRateDataService {
|
|||||||
await this.prismaService.settings.findMany({
|
await this.prismaService.settings.findMany({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((userSettings) => {
|
).forEach((userSettings) => {
|
||||||
currencies.push(userSettings.currency);
|
currencies.push(userSettings.currency);
|
||||||
@ -192,12 +187,25 @@ export class ExchangeRateDataService {
|
|||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((symbolProfile) => {
|
).forEach((symbolProfile) => {
|
||||||
currencies.push(symbolProfile.currency);
|
currencies.push(symbolProfile.currency);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
11
apps/api/src/services/market-data.module.ts
Normal file
11
apps/api/src/services/market-data.module.ts
Normal 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 {}
|
87
apps/api/src/services/market-data.service.ts
Normal file
87
apps/api/src/services/market-data.service.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketDataService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async get({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<MarketData> {
|
||||||
|
return await this.prismaService.marketData.findFirst({
|
||||||
|
where: {
|
||||||
|
symbol,
|
||||||
|
date: resetHours(date)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRange({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
}: {
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
symbols: string[];
|
||||||
|
}): Promise<MarketData[]> {
|
||||||
|
return await this.prismaService.marketData.findMany({
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
symbol: 'asc'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
date: dateQuery,
|
||||||
|
symbol: {
|
||||||
|
in: symbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||||
|
where: Prisma.MarketDataWhereUniqueInput;
|
||||||
|
}): Promise<MarketData> {
|
||||||
|
const { data, where } = params;
|
||||||
|
|
||||||
|
return this.prismaService.marketData.upsert({
|
||||||
|
where,
|
||||||
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: where.date_symbol.date,
|
||||||
|
marketPrice: data.marketPrice,
|
||||||
|
symbol: where.date_symbol.symbol
|
||||||
|
},
|
||||||
|
update: { marketPrice: data.marketPrice }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
6
apps/api/src/services/property/property.dto.ts
Normal file
6
apps/api/src/services/property/property.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class PropertyDto {
|
||||||
|
@IsString()
|
||||||
|
value: string;
|
||||||
|
}
|
11
apps/api/src/services/property/property.module.ts
Normal file
11
apps/api/src/services/property/property.module.ts
Normal 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 {}
|
49
apps/api/src/services/property/property.service.ts
Normal file
49
apps/api/src/services/property/property.service.ts
Normal 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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
11
apps/api/src/services/symbol-profile.module.ts
Normal file
11
apps/api/src/services/symbol-profile.module.ts
Normal 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 {}
|
@ -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;
|
||||||
|
}) ?? {}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "es2015"
|
"target": "es2015"
|
||||||
},
|
},
|
||||||
"exclude": ["**/*.spec.ts"],
|
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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$)']
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/changelog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/changelog/changelog-page.module').then(
|
||||||
|
(m) => m.ChangelogPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -33,6 +40,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -47,6 +59,13 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||||
|
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -66,6 +85,13 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(m) => m.PortfolioPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'portfolio/activities',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||||
|
(m) => m.TransactionsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio/allocations',
|
path: 'portfolio/allocations',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -87,13 +113,6 @@ const routes: Routes = [
|
|||||||
(m) => m.ReportPageModule
|
(m) => m.ReportPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'portfolio/transactions',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
|
||||||
(m) => m.TransactionsPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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() {
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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');
|
||||||
|
@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
|
||||||
import { AccountsTableComponent } from './accounts-table.component';
|
import { AccountsTableComponent } from './accounts-table.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user