Compare commits
129 Commits
Author | SHA1 | Date | |
---|---|---|---|
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 | |||
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 | |||
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 | |||
30a592b524 | |||
de94494aa0 | |||
d3c6788ad5 | |||
3ec4a73b35 | |||
1050bfa098 | |||
595ec1d7b4 | |||
c8389599b6 | |||
8769fe4c90 | |||
4219e1121e | |||
f558eb8de8 | |||
fe2bd6eea8 | |||
035052be99 | |||
bcdd2780b3 | |||
22d1ed7920 | |||
39d9828f9f | |||
6333aa972d | |||
554f2f861f | |||
dcee651098 | |||
508a48f4c3 | |||
8466e3d73f | |||
9ae9904389 | |||
af022ae316 | |||
5cd6edaf3a | |||
98be8745d9 | |||
861dff9210 | |||
f2364eed10 | |||
d5392de7c9 | |||
0f72673ef4 | |||
641fe4e8f4 | |||
18e06bb6e6 | |||
5b588c2000 | |||
162d19fa44 | |||
4a815d2031 | |||
d2aeeb3e88 | |||
ba926ffcf2 | |||
5ea455b98b | |||
39f315aba0 | |||
df2dfc20a1 | |||
81e83d4cea | |||
5d4156ecec | |||
4693a8baa2 | |||
773444b1e2 | |||
3c46bde8d5 | |||
63ee33b685 | |||
bc87c0a3e1 | |||
caa9fc3efa | |||
9ed82ac82b | |||
9c9ca4ab1e | |||
b0b0942162 | |||
9cbf789c22 | |||
ee5ab05d8a | |||
20731c67cb | |||
bf8856ad19 | |||
a31d79821d | |||
48ab862bb6 | |||
ba234a470e | |||
ccae660104 | |||
21ed91d184 | |||
5fd413e57e | |||
4c194c938a | |||
a4d049e53d | |||
f9c4408126 | |||
d046f1d498 | |||
ad96d6e53e | |||
747e5b63fa | |||
b1187cf880 | |||
ba9e6eab58 | |||
01feead017 | |||
6a0cfb8f77 | |||
6386786ac0 | |||
d3be6577c8 | |||
73a967a7e5 | |||
836ff6ec13 | |||
c5bb3023d3 | |||
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c | |||
e14f08a8fb | |||
72c065a59d | |||
98dac4052a | |||
2083d28d02 | |||
addd5c36d9 | |||
aad8f77093 | |||
a904208d06 | |||
2733b78044 | |||
b43b515df1 | |||
70e14b4d3c | |||
0f7d1b7d59 | |||
c2ab6a6c44 | |||
c71a4c078e | |||
e17b217032 | |||
408e08d43c | |||
2fa324702f | |||
05b0efef82 | |||
7c91727eb1 | |||
0ee2258af8 | |||
308b218487 | |||
26694adb8e | |||
77936e3bf3 | |||
13090bf6b5 | |||
b898c0678d | |||
3330ae70b6 | |||
96a615dc5d | |||
98f44323da | |||
8adacd9760 | |||
908aba170d | |||
d599797a65 | |||
8ac1272a9d | |||
0a85a56c67 | |||
4ad5590838 |
@ -1 +1,2 @@
|
||||
/dist
|
||||
/test/import
|
||||
|
11
.storybook/main.js
Normal file
11
.storybook/main.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
};
|
10
.storybook/tsconfig.json
Normal file
10
.storybook/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"exclude": [
|
||||
"../**/*.spec.js",
|
||||
"../**/*.spec.ts",
|
||||
"../**/*.spec.tsx",
|
||||
"../**/*.spec.jsx"
|
||||
],
|
||||
"include": ["../**/*"]
|
||||
}
|
367
CHANGELOG.md
367
CHANGELOG.md
@ -5,6 +5,371 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.63.0 - 19.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a public page to share your portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.62.0 - 17.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation message of the import functionality for transactions
|
||||
|
||||
## 1.61.0 - 15.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the import functionality for transactions by `csv` files
|
||||
- Introduced the primary data source
|
||||
|
||||
### Changed
|
||||
|
||||
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
|
||||
|
||||
## 1.60.0 - 13.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation of the import functionality for transactions
|
||||
- Valid data types
|
||||
- Maximum number of orders
|
||||
- No duplicate orders
|
||||
- Data provider service returns data for the `dataSource` / `symbol` pair
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the page layouts
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the broken line charts showing value labels
|
||||
|
||||
## 1.59.0 - 11.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the values of the global heat map to fixed-point notation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the links of cryptocurrency assets in the positions table
|
||||
- Fixed various values in the impersonation mode which have not been nullified
|
||||
|
||||
## 1.58.1 - 03.10.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
|
||||
|
||||
## 1.58.0 - 02.10.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
|
||||
- Improved the tooltips of the allocations page
|
||||
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
|
||||
|
||||
## 1.57.0 - 29.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a protection for endpoints (subscriptions)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reformatted the exchange rates table in the admin control panel
|
||||
|
||||
## 1.56.0 - 25.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a story for the line chart component
|
||||
- Added a story for the portfolio proportion chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the navigation to always show the portfolio page
|
||||
- Migrated the data type of currencies from `enum` to `string` in the database
|
||||
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
|
||||
- Respected the accounts' currencies in the exchange rate service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the actions from the accounts table in the _Presenter View_
|
||||
- Hid the actions from the transactions table in the _Presenter View_
|
||||
- Fixed the data gathering of the initial project setup (database seeding)
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.55.0 - 20.09.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the default value of the data source attribute
|
||||
- Upgraded `@storybook` dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the create or edit transaction dialog
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.54.0 - 18.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data source attribute to the symbol profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the data source attribute in the data provider service
|
||||
- Respected the data source attribute in the symbol data endpoint
|
||||
- Improved the search functionality of the data management (multiple data sources)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
|
||||
- Hid the sign if the performance is zero in the value component
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.53.0 - 13.09.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the annualized performance calculation
|
||||
- Changed the data gathering selection from distinct orders to symbol profiles
|
||||
|
||||
## 1.52.0 - 11.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the annualized performance to the portfolio summary tab on the home page
|
||||
- Added the Ghostfolio Slack channel to the about page
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sign in with fingerprint for some android devices
|
||||
|
||||
## 1.51.0 - 11.09.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Provided the name in the portfolio position endpoint
|
||||
|
||||
## 1.50.0 - 11.09.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the _Fear & Greed Index_ (market mood)
|
||||
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
|
||||
|
||||
## 1.49.0 - 08.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added labels to the allocation chart by symbol on desktop
|
||||
|
||||
## 1.48.0 - 07.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the attribute `precision` in the value component
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the performance in the _Presenter View_
|
||||
|
||||
## 1.47.1 - 06.09.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the search functionality for cryptocurrency symbols
|
||||
|
||||
## 1.46.0 - 05.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the statistics section on the about page by the _GitHub_ contributors count
|
||||
- Set up _Storybook_
|
||||
- Added a story for the logo component
|
||||
- Added a story for the no transactions info component
|
||||
- Added a story for the trend indicator component
|
||||
- Added a story for the value component
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched from gross to net performance
|
||||
- Restructured the portfolio summary tab on the home page (fees and net performance)
|
||||
|
||||
## 1.45.0 - 04.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link below the holdings to manage the transactions
|
||||
- Added the allocation chart by symbol
|
||||
|
||||
### Changed
|
||||
|
||||
- Restructured the allocations page
|
||||
- Upgraded `angular` from version `12.0.4` to `12.2.4`
|
||||
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
|
||||
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
|
||||
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the value formatting for integers (transactions count)
|
||||
|
||||
## 1.44.0 - 30.08.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the sub classification of assets by cash
|
||||
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filtered out positions without any quantity in the positions table
|
||||
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
|
||||
|
||||
## 1.43.0 - 24.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the data management of symbol profile data by countries (automated for stocks)
|
||||
- Added a fallback for initially loading currencies if historical data is not yet available
|
||||
|
||||
## 1.42.0 - 22.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the subscription type to the users table of the admin control panel
|
||||
- Introduced the sub classification of assets
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.41.0 - 21.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link to the system status page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||
- Improved the styling of the tables
|
||||
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the allocation chart by account (wrong calculation)
|
||||
- Fixed an issue in the allocation chart by account (missing cash accounts)
|
||||
|
||||
## 1.40.0 - 19.08.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the fault tolerance of the portfolio details endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the node engine version mismatch in `package.json`
|
||||
- Fixed an issue on the buy date in the position detail dialog
|
||||
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `GBp` to `GBP`)
|
||||
|
||||
## 1.39.0 - 16.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added an option to hide absolute values like performances and quantities (_Restricted View_)
|
||||
|
||||
### Changed
|
||||
|
||||
- Restructured the allocations page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the performance in the portfolio summary tab on the home page (impersonation mode)
|
||||
- Fixed various values in the impersonation mode which have not been nullified
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the current net performance
|
||||
- Removed the read foreign portfolio permission
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.38.0 - 14.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the overview menu item on mobile
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the exchange rate service
|
||||
- Improved the users table in the admin control panel
|
||||
|
||||
## 1.37.0 - 13.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the calculated net worth to the portfolio summary tab on the home page
|
||||
- Added the calculated time in market to the portfolio summary tab on the home page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the tabs on the home page
|
||||
- Restructured the portfolio summary tab on the home page
|
||||
- Upgraded `angular-material-css-vars` from version `2.1.0` to `2.1.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the position detail chart if there are missing historical data around the first buy date
|
||||
- Fixed the snack bar background color in dark mode
|
||||
- Fixed the search functionality for symbols (filter for supported currencies)
|
||||
|
||||
## 1.36.0 - 09.08.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data gathering handling on server restart
|
||||
- Respected the cash balance on the allocations page
|
||||
- Eliminated the name from the scraper configuration
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed hidden cryptocurrency holdings
|
||||
|
||||
## 1.35.0 - 08.08.2021
|
||||
|
||||
### Changed
|
||||
@ -21,7 +386,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:push`)
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.34.0 - 07.08.2021
|
||||
|
||||
|
12
README.md
12
README.md
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
@ -34,7 +34,7 @@
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood).
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Dark Mode
|
||||
@ -116,6 +116,10 @@ Please make sure you have completed the instructions from [_Setup_](#Setup).
|
||||
|
||||
Run `yarn start:client`
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `yarn start:storybook`
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
@ -124,7 +128,7 @@ Run `yarn test`
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
|
||||
## License
|
||||
|
||||
|
89
angular.json
89
angular.json
@ -6,13 +6,16 @@
|
||||
"defaultProject": "api",
|
||||
"schematics": {
|
||||
"@nrwl/angular:application": {
|
||||
"linter": "eslint",
|
||||
"unitTestRunner": "jest",
|
||||
"e2eTestRunner": "cypress"
|
||||
},
|
||||
"@nrwl/angular:library": {
|
||||
"linter": "eslint",
|
||||
"unitTestRunner": "jest"
|
||||
},
|
||||
"@nrwl/nest": {}
|
||||
"@nrwl/nest": {},
|
||||
"@nrwl/angular:component": {}
|
||||
},
|
||||
"projects": {
|
||||
"api": {
|
||||
@ -239,6 +242,90 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@nrwl/storybook:storybook",
|
||||
"options": {
|
||||
"uiFramework": "@storybook/angular",
|
||||
"port": 4400,
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@nrwl/storybook:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"uiFramework": "@storybook/angular",
|
||||
"outputPath": "dist/storybook/ui",
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui-e2e": {
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -24,8 +24,18 @@ export class AccessController {
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return {
|
||||
granteeAlias: access.GranteeUser?.alias,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
granteeAlias: access.GranteeUser.alias
|
||||
granteeAlias: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AccessController],
|
||||
exports: [AccessService],
|
||||
imports: [],
|
||||
providers: [AccessService, PrismaService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -7,6 +7,17 @@ import { Prisma } from '@prisma/client';
|
||||
export class AccessService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async access(
|
||||
accessWhereInput: Prisma.AccessWhereInput
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accesses(params: {
|
||||
include?: Prisma.AccessInclude;
|
||||
skip?: number;
|
||||
|
@ -1,3 +1,4 @@
|
||||
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 {
|
||||
@ -5,7 +6,7 @@ import {
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -33,7 +34,8 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -84,25 +86,22 @@ export class AccountController {
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<AccountModel[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accounts = await this.accountService.getAccounts(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
let accounts = await this.accountService.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accounts = nullifyValuesInObjects(accounts, [
|
||||
'balance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice'
|
||||
|
@ -1,32 +1,26 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
RedisCacheModule,
|
||||
PrismaModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AccountController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AccountService]
|
||||
})
|
||||
export class AccountModule {}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Currency, Order, Prisma } from '@prisma/client';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@ -41,7 +41,12 @@ export class AccountService {
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByInput;
|
||||
}): Promise<Account[]> {
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.account.findMany({
|
||||
@ -72,9 +77,25 @@ export class AccountService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string) {
|
||||
const accounts = await this.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
const result = { ...account, transactionCount: account.Order.length };
|
||||
|
||||
delete result.Order;
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async getCashDetails(
|
||||
aUserId: string,
|
||||
aCurrency: Currency
|
||||
aCurrency: string
|
||||
): Promise<CashDetails> {
|
||||
let totalCashBalance = 0;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@ -9,7 +9,7 @@ export class CreateAccountDto {
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@ -9,7 +9,7 @@ export class UpdateAccountDto {
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,31 +1,25 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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 { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
AdminService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AdminService],
|
||||
exports: [AdminService]
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
@ -1,65 +1,41 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
exchangeRates: [
|
||||
{
|
||||
label1: Currency.EUR,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.EUR,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.GBP,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.GBP,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.EUR,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.EUR
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.GBP,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.GBP
|
||||
)
|
||||
}
|
||||
],
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: baseCurrency,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
baseCurrency,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
@ -68,18 +44,15 @@ export class AdminService {
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
||||
if (lastDataGathering?.value) {
|
||||
return new Date(lastDataGathering.value);
|
||||
if (lastDataGathering) {
|
||||
return lastDataGathering;
|
||||
}
|
||||
|
||||
const dataGatheringInProgress =
|
||||
await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
await this.dataGatheringService.getIsInProgress();
|
||||
|
||||
if (dataGatheringInProgress) {
|
||||
return 'IN_PROGRESS';
|
||||
@ -88,8 +61,8 @@ export class AdminService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics() {
|
||||
return await this.prismaService.user.findMany({
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
@ -107,7 +80,8 @@ export class AdminService {
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
@ -116,5 +90,30 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
alias,
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
public constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
@ -15,17 +15,12 @@ export class AppController {
|
||||
private async initialize() {
|
||||
this.redisCacheService.reset();
|
||||
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
const isDataGatheringInProgress =
|
||||
await this.dataGatheringService.getIsInProgress();
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
// Prepare for automatical data gather if not locked
|
||||
await this.prismaService.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
if (isDataGatheringInProgress) {
|
||||
// Prepare for automatical data gathering, if hung up in progress state
|
||||
await this.dataGatheringService.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +1,30 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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 { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
import { CronService } from '../services/cron.service';
|
||||
import { DataGatheringService } from '../services/data-gathering.service';
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { ExperimentalModule } from './experimental/experimental.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
@ -43,13 +38,17 @@ import { UserModule } from './user/user.module';
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
CoreModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ExperimentalModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
@ -71,17 +70,6 @@ import { UserModule } from './user/user.module';
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CronService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
|
@ -62,10 +62,10 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-attestation-options')
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateAttestationOptions() {
|
||||
return this.webAuthService.generateAttestationOptions();
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
@ -10,19 +11,18 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
GenerateAssertionOptionsOpts,
|
||||
GenerateAttestationOptionsOpts,
|
||||
VerifiedAssertion,
|
||||
VerifiedAttestation,
|
||||
VerifyAssertionResponseOpts,
|
||||
VerifyAttestationResponseOpts,
|
||||
generateAssertionOptions,
|
||||
generateAttestationOptions,
|
||||
verifyAssertionResponse,
|
||||
verifyAttestationResponse
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
@ -46,10 +46,10 @@ export class WebAuthService {
|
||||
return this.configurationService.get('ROOT_URL');
|
||||
}
|
||||
|
||||
public async generateAttestationOptions() {
|
||||
public async generateRegistrationOptions() {
|
||||
const user = this.request.user;
|
||||
|
||||
const opts: GenerateAttestationOptionsOpts = {
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
@ -63,7 +63,7 @@ export class WebAuthService {
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateAttestationOptions(opts);
|
||||
const options = generateRegistrationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -84,27 +84,27 @@ export class WebAuthService {
|
||||
const user = this.request.user;
|
||||
const expectedChallenge = user.authChallenge;
|
||||
|
||||
let verification: VerifiedAttestation;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
const opts: VerifyAttestationResponseOpts = {
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = await verifyAttestationResponse(opts);
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
const { verified, attestationInfo } = verification;
|
||||
const { registrationInfo, verified } = verification;
|
||||
|
||||
const devices = await this.deviceService.authDevices({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (verified && attestationInfo) {
|
||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
||||
if (registrationInfo && verified) {
|
||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
@ -115,9 +115,9 @@ export class WebAuthService {
|
||||
* Add the returned device to the user's list of devices
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
counter,
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
@ -138,20 +138,20 @@ export class WebAuthService {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const opts: GenerateAssertionOptionsOpts = {
|
||||
timeout: 60000,
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
type: 'public-key',
|
||||
transports: ['internal']
|
||||
transports: ['internal'],
|
||||
type: 'public-key'
|
||||
}
|
||||
],
|
||||
userVerification: 'preferred',
|
||||
rpID: this.rpID
|
||||
rpID: this.rpID,
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred'
|
||||
};
|
||||
|
||||
const options = generateAssertionOptions(opts);
|
||||
const options = generateAuthenticationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -177,29 +177,29 @@ export class WebAuthService {
|
||||
|
||||
const user = await this.userService.user({ id: device.userId });
|
||||
|
||||
let verification: VerifiedAssertion;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAssertionResponseOpts = {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
}
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = verifyAssertionResponse(opts);
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
const { verified, assertionInfo } = verification;
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified) {
|
||||
device.counter = assertionInfo.newCounter;
|
||||
device.counter = authenticationInfo.newCounter;
|
||||
|
||||
await this.deviceService.updateAuthDevice({
|
||||
data: device,
|
||||
|
7
apps/api/src/app/cache/cache.controller.ts
vendored
7
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,11 +1,10 @@
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
|
17
apps/api/src/app/cache/cache.module.ts
vendored
17
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,13 +1,22 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { CacheController } from './cache.controller';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [DataProviderModule, ExchangeRateDataModule, RedisCacheModule],
|
||||
controllers: [CacheController],
|
||||
providers: [CacheService, PrismaService]
|
||||
providers: [
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
PrismaService
|
||||
]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
12
apps/api/src/app/cache/cache.service.ts
vendored
12
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,16 +1,14 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly dataGaterhingService: DataGatheringService
|
||||
) {}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
await this.prismaService.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
await this.dataGaterhingService.reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CurrentRateService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
MarketDataService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class CoreModule {}
|
@ -1,8 +0,0 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface GetValueParams {
|
||||
currency: Currency;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: Currency;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: Currency };
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
userCurrency: Currency;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import { Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
|
@ -1,34 +1,23 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
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: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
RedisCacheModule,
|
||||
PrismaModule
|
||||
],
|
||||
controllers: [ExperimentalController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
ExperimentalService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AccountService, ExperimentalService]
|
||||
})
|
||||
export class ExperimentalModule {}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -11,8 +10,7 @@ export class ExperimentalService {
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly rulesService: RulesService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async getBenchmark(aSymbol: string) {
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface Data {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
value: number;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
@ -1,32 +1,22 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExportService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Order } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
orders: Partial<Order>[];
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
orders: Order[];
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -42,7 +42,10 @@ export class ImportController {
|
||||
console.error(error);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
{
|
||||
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
message: [error.message]
|
||||
},
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
@ -1,34 +1,24 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImportService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(private readonly orderService: OrderService) {}
|
||||
private static MAX_ORDERS_TO_IMPORT = 20;
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
@ -14,7 +20,10 @@ export class ImportService {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await this.validateOrders({ orders, userId });
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
@ -25,6 +34,11 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -37,4 +51,53 @@ export class ImportService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateOrders({
|
||||
orders,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}) {
|
||||
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
||||
throw new Error('Too many transactions');
|
||||
}
|
||||
|
||||
const existingOrders = await this.orderService.orders({
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of orders.entries()) {
|
||||
const duplicateOrder = existingOrders.find((order) => {
|
||||
return (
|
||||
order.currency === currency &&
|
||||
order.dataSource === dataSource &&
|
||||
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
||||
order.fee === fee &&
|
||||
order.quantity === quantity &&
|
||||
order.symbol === symbol &&
|
||||
order.type === type &&
|
||||
order.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateOrder) {
|
||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||
}
|
||||
|
||||
const result = await this.dataProviderService.get([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (result[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -8,12 +11,19 @@ import { InfoService } from './info.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [ConfigurationService, InfoService, PrismaService]
|
||||
providers: [
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
InfoService,
|
||||
PrismaService
|
||||
]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@ -15,6 +17,9 @@ export class InfoService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -54,9 +59,10 @@ export class InfoService {
|
||||
...info,
|
||||
globalPermissions,
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
};
|
||||
@ -88,6 +94,27 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const contributors = await get();
|
||||
return contributors?.length;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
@ -116,11 +143,10 @@ export class InfoService {
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
||||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
|
||||
return lastDataGathering ?? null;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
@ -130,11 +156,13 @@ export class InfoService {
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
|
||||
return {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers
|
||||
};
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
@ -23,7 +23,7 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(Type, { each: true })
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
|
@ -1,3 +1,4 @@
|
||||
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 {
|
||||
@ -5,7 +6,7 @@ import {
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -34,7 +35,8 @@ export class OrderController {
|
||||
public constructor(
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -89,11 +91,8 @@ export class OrderController {
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
}
|
||||
|
@ -1,34 +1,28 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { OrderController } from './order.controller';
|
||||
import { OrderService } from './order.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [OrderController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CacheService, OrderService],
|
||||
exports: [OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
@ -5,8 +6,6 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
@ -57,7 +56,9 @@ export class OrderService {
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||
this.dataGatheringService.gatherProfileData([
|
||||
{ dataSource: data.dataSource, symbol: data.symbol }
|
||||
]);
|
||||
|
||||
await this.cacheService.flush();
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/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 { Currency, MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
jest.mock('./market-data.service', () => {
|
||||
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
|
||||
date,
|
||||
symbol,
|
||||
createdAt: date,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
@ -72,12 +75,13 @@ describe('CurrentRateService', () => {
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
@ -92,10 +96,10 @@ describe('CurrentRateService', () => {
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: Currency.USD,
|
||||
currency: 'USD',
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: Currency.CHF
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
@ -105,13 +109,13 @@ describe('CurrentRateService', () => {
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
currencies: { AMZN: Currency.USD },
|
||||
currencies: { AMZN: 'USD' },
|
||||
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||
dateQuery: {
|
||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||
},
|
||||
symbols: ['AMZN'],
|
||||
userCurrency: Currency.CHF
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject([
|
||||
{
|
@ -1,13 +1,13 @@
|
||||
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/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 { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
@Injectable()
|
||||
@ -25,11 +25,16 @@ export class CurrentRateService {
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([symbol]);
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{
|
||||
symbol,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||
}
|
||||
]);
|
||||
return {
|
||||
symbol,
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||
symbol: symbol
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -55,8 +60,8 @@ export class CurrentRateService {
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
symbols,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
const includeToday =
|
||||
@ -75,24 +80,31 @@ export class CurrentRateService {
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService.get(symbols).then((dataResultProvider) => {
|
||||
const result = [];
|
||||
for (const symbol of symbols) {
|
||||
result.push({
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[symbol]?.marketPrice ?? 0,
|
||||
dataResultProvider?.[symbol]?.currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
this.dataProviderService
|
||||
.get(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
||||
0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
@ -6,6 +6,9 @@ export interface CurrentPositions {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
totalInvestment: Big;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export interface GetValueParams {
|
||||
currency: string;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: string;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import { DateQuery } from './date-query.interface';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: string };
|
||||
dataGatheringItems: IDataGatheringItem[];
|
||||
dateQuery: DateQuery;
|
||||
userCurrency: string;
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
date: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
@ -1,8 +1,6 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
@ -11,6 +9,9 @@ export interface PortfolioPositionDetail {
|
||||
marketPrice: number;
|
||||
maxPrice: number;
|
||||
minPrice: number;
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
|
@ -4,5 +4,6 @@ export interface TimelinePeriod {
|
||||
date: string;
|
||||
grossPerformance: Big;
|
||||
investment: Big;
|
||||
netPerformance: Big;
|
||||
value: Big;
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
quantity: Big;
|
@ -0,0 +1,6 @@
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,23 +1,13 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
@ -25,14 +15,26 @@ import {
|
||||
max,
|
||||
min
|
||||
} from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
import { flatten, isNumber } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from './interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculator {
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor(
|
||||
private currentRateService: CurrentRateService,
|
||||
private currency: Currency
|
||||
private currency: string
|
||||
) {}
|
||||
|
||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||
@ -57,6 +59,8 @@ export class PortfolioCalculator {
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
@ -71,6 +75,8 @@ export class PortfolioCalculator {
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
@ -100,6 +106,23 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
@ -111,11 +134,14 @@ export class PortfolioCalculator {
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
return {
|
||||
currentValue: new Big(0),
|
||||
hasErrors: false,
|
||||
positions: [],
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
currentValue: new Big(0),
|
||||
netAnnualizedPerformance: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
};
|
||||
}
|
||||
@ -129,12 +155,15 @@ export class PortfolioCalculator {
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const symbols = new Set<string>();
|
||||
const currencies: { [symbol: string]: Currency } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
symbols.add(item.symbol);
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
@ -154,10 +183,10 @@ export class PortfolioCalculator {
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
symbols: Array.from(symbols),
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
@ -180,7 +209,9 @@ export class PortfolioCalculator {
|
||||
const startString = format(start, DATE_FORMAT);
|
||||
|
||||
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
|
||||
const grossPerformance: { [symbol: string]: Big } = {};
|
||||
const netPerformance: { [symbol: string]: Big } = {};
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
@ -189,6 +220,7 @@ export class PortfolioCalculator {
|
||||
const invalidSymbols = [];
|
||||
const lastInvestments: { [symbol: string]: Big } = {};
|
||||
const lastQuantities: { [symbol: string]: Big } = {};
|
||||
const lastFees: { [symbol: string]: Big } = {};
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||
@ -201,10 +233,6 @@ export class PortfolioCalculator {
|
||||
|
||||
const items = this.transactionPoints[i].items;
|
||||
for (const item of items) {
|
||||
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
|
||||
if (!oldHoldingPeriodReturn) {
|
||||
oldHoldingPeriodReturn = new Big(1);
|
||||
}
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
@ -223,6 +251,13 @@ export class PortfolioCalculator {
|
||||
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||
let initialValue = itemValue?.mul(lastQuantity);
|
||||
let investedValue = itemValue?.mul(item.quantity);
|
||||
const isFirstOrderAndIsStartBeforeCurrentDate =
|
||||
i === firstIndex &&
|
||||
isBefore(parseDate(this.transactionPoints[i].date), start);
|
||||
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
|
||||
const fee = isFirstOrderAndIsStartBeforeCurrentDate
|
||||
? new Big(0)
|
||||
: item.fee.minus(lastFee);
|
||||
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||
initialValue = item.investment;
|
||||
investedValue = item.investment;
|
||||
@ -246,18 +281,26 @@ export class PortfolioCalculator {
|
||||
);
|
||||
|
||||
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||
holdingPeriodReturns[item.symbol] =
|
||||
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
|
||||
let oldGrossPerformance = grossPerformance[item.symbol];
|
||||
if (!oldGrossPerformance) {
|
||||
oldGrossPerformance = new Big(0);
|
||||
}
|
||||
const currentPerformance = endValue.minus(investedValue);
|
||||
grossPerformance[item.symbol] =
|
||||
oldGrossPerformance.plus(currentPerformance);
|
||||
holdingPeriodReturns[item.symbol] = (
|
||||
holdingPeriodReturns[item.symbol] ?? new Big(1)
|
||||
).mul(holdingPeriodReturn);
|
||||
grossPerformance[item.symbol] = (
|
||||
grossPerformance[item.symbol] ?? new Big(0)
|
||||
).plus(endValue.minus(investedValue));
|
||||
|
||||
const netHoldingPeriodReturn = endValue.div(
|
||||
initialValue.plus(cashFlow).plus(fee)
|
||||
);
|
||||
netHoldingPeriodReturns[item.symbol] = (
|
||||
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
|
||||
).mul(netHoldingPeriodReturn);
|
||||
netPerformance[item.symbol] = (
|
||||
netPerformance[item.symbol] ?? new Big(0)
|
||||
).plus(endValue.minus(investedValue).minus(fee));
|
||||
}
|
||||
lastInvestments[item.symbol] = item.investment;
|
||||
lastQuantities[item.symbol] = item.quantity;
|
||||
lastFees[item.symbol] = item.fee;
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,6 +314,7 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: isValid
|
||||
? grossPerformance[item.symbol] ?? null
|
||||
@ -281,15 +325,17 @@ export class PortfolioCalculator {
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
|
||||
netPerformancePercentage:
|
||||
isValid && netHoldingPeriodReturns[item.symbol]
|
||||
? netHoldingPeriodReturns[item.symbol].minus(1)
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
}
|
||||
const overall = this.calculateOverallGrossPerformance(
|
||||
positions,
|
||||
initialValues
|
||||
);
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
@ -377,7 +423,7 @@ export class PortfolioCalculator {
|
||||
return flatten(timelinePeriods);
|
||||
}
|
||||
|
||||
private calculateOverallGrossPerformance(
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [p: string]: Big }
|
||||
) {
|
||||
@ -386,7 +432,14 @@ export class PortfolioCalculator {
|
||||
let totalInvestment = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
let netAnnualizedPerformance = new Big(0);
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
@ -400,6 +453,7 @@ export class PortfolioCalculator {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
@ -413,6 +467,18 @@ export class PortfolioCalculator {
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(
|
||||
today,
|
||||
parseDate(currentPosition.firstBuyDate)
|
||||
),
|
||||
netPerformancePercent: currentPosition.netPerformancePercentage
|
||||
}).mul(currentInitialValue)
|
||||
);
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
console.error(
|
||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||
@ -424,6 +490,10 @@ export class PortfolioCalculator {
|
||||
if (!completeInitialValue.eq(0)) {
|
||||
grossPerformancePercentage =
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
netPerformancePercentage =
|
||||
netPerformancePercentage.div(completeInitialValue);
|
||||
netAnnualizedPerformance =
|
||||
netAnnualizedPerformance.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -431,6 +501,9 @@ export class PortfolioCalculator {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netAnnualizedPerformance,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
};
|
||||
}
|
||||
@ -441,30 +514,35 @@ export class PortfolioCalculator {
|
||||
endDate: Date
|
||||
): Promise<TimelinePeriod[]> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: Currency } = {};
|
||||
const symbols: string[] = [];
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols.push(item.symbol);
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (symbols.length > 0) {
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
symbols,
|
||||
currencies,
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
@ -489,7 +567,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results: TimelinePeriod[] = [];
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
@ -512,11 +590,13 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const grossPerformance = value.minus(investment);
|
||||
const result = {
|
||||
date: currentDateAsString,
|
||||
grossPerformance: value.minus(investment),
|
||||
grossPerformance,
|
||||
investment,
|
||||
value
|
||||
value,
|
||||
date: currentDateAsString,
|
||||
netPerformance: grossPerformance.minus(fees)
|
||||
};
|
||||
results.push(result);
|
||||
}
|
@ -1,22 +1,21 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioOverview,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -43,27 +42,35 @@ import { PortfolioService } from './portfolio.service';
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Get('/investments')
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@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 &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
@ -76,7 +83,7 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return investments;
|
||||
return <any>res.json(investments);
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@ -104,11 +111,8 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
@ -135,44 +139,37 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
let details: { [symbol: string]: PortfolioPosition } = {};
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
try {
|
||||
details = await this.portfolioService.getDetails(
|
||||
impersonationUserId,
|
||||
range
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
): Promise<PortfolioDetails> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
if (hasNotDefinedValuesInObject(details)) {
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range
|
||||
);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(details)
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return portfolioPosition.investment;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const totalValue = Object.values(details)
|
||||
const totalValue = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
@ -182,50 +179,22 @@ export class PortfolioController {
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(details)) {
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
|
||||
for (const [account, { current, original }] of Object.entries(
|
||||
portfolioPosition.accounts
|
||||
)) {
|
||||
portfolioPosition.accounts[account].current = current / totalValue;
|
||||
portfolioPosition.accounts[account].original =
|
||||
original / totalInvestment;
|
||||
}
|
||||
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(details);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getOverview(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioOverview> {
|
||||
let overview = await this.portfolioService.getOverview(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
overview = nullifyValuesInObject(overview, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'fees',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
return overview;
|
||||
return <any>res.json({ accounts, holdings });
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@ -246,15 +215,11 @@ export class PortfolioController {
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
}
|
||||
@ -278,9 +243,103 @@ export class PortfolioController {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
result.positions = result.positions.map((position) => {
|
||||
return nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return <any>res.json(result);
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
|
||||
if (!access) {
|
||||
res.status(StatusCodes.NOT_FOUND);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId
|
||||
);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
holdings: {}
|
||||
};
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter((holding) => {
|
||||
return holding.assetClass === 'EQUITY';
|
||||
})
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||
countries: [],
|
||||
name: portfolioPosition.name,
|
||||
sectors: [],
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(portfolioPublicDetails);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
summary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'fees',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@ -294,13 +353,15 @@ export class PortfolioController {
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
position = nullifyValuesInObject(position, ['grossPerformance']);
|
||||
position = nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
}
|
||||
|
||||
return position;
|
||||
@ -315,8 +376,19 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioReport> {
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ rules: [] });
|
||||
}
|
||||
|
||||
return <any>(
|
||||
res.json(await this.portfolioService.getReport(impersonationId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,50 +1,42 @@
|
||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
CurrentRateService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
MarketDataService,
|
||||
OrderService,
|
||||
PortfolioService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
SymbolProfileService,
|
||||
UserService,
|
||||
YahooFinanceService
|
||||
SymbolProfileService
|
||||
]
|
||||
})
|
||||
export class PortfolioModule {}
|
||||
|
@ -1,10 +1,13 @@
|
||||
// TODO ///////////
|
||||
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
|
||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
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 { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||
@ -14,31 +17,35 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
UNKNOWN_KEY,
|
||||
baseCurrency,
|
||||
ghostfolioCashSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioOverview,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
import type {
|
||||
DateRange,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
endOfToday,
|
||||
@ -58,6 +65,7 @@ import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
@ -76,7 +84,7 @@ export class PortfolioService {
|
||||
public async getInvestments(
|
||||
aImpersonationId: string
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -104,7 +112,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -140,42 +148,18 @@ export class PortfolioService {
|
||||
.map((timelineItem) => ({
|
||||
date: timelineItem.date,
|
||||
marketPrice: timelineItem.value,
|
||||
value: timelineItem.grossPerformance.toNumber()
|
||||
value: timelineItem.netPerformance.toNumber()
|
||||
}));
|
||||
}
|
||||
|
||||
public async getOverview(
|
||||
aImpersonationId: string
|
||||
): Promise<PortfolioOverview> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({ userId });
|
||||
const fees = this.getFees(orders);
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||
return {
|
||||
committedFunds: totalBuy - totalSell,
|
||||
fees,
|
||||
cash: balance,
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
};
|
||||
}
|
||||
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aUserId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
@ -186,7 +170,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {};
|
||||
return { accounts: {}, holdings: {}, hasErrors: false };
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
@ -197,19 +181,29 @@ export class PortfolioService {
|
||||
startDate
|
||||
);
|
||||
|
||||
if (currentPositions.hasErrors) {
|
||||
throw new Error('Missing information');
|
||||
}
|
||||
const cashDetails = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const result: { [symbol: string]: PortfolioPosition } = {};
|
||||
const totalValue = currentPositions.currentValue;
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balance
|
||||
);
|
||||
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
|
||||
|
||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const symbols = currentPositions.positions.map(
|
||||
(position) => position.symbol
|
||||
);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.get(symbols),
|
||||
this.dataProviderService.get(dataGatheringItems),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
]);
|
||||
|
||||
@ -222,28 +216,33 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
|
||||
|
||||
for (const item of currentPositions.positions) {
|
||||
if (item.quantity.lte(0)) {
|
||||
// Ignore positions without any quantity
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = item.quantity.mul(item.marketPrice);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
result[item.symbol] = {
|
||||
accounts,
|
||||
holdings[item.symbol] = {
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(currentPositions.totalInvestment)
|
||||
.toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
grossPerformance: item.grossPerformance.toNumber(),
|
||||
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
investment: item.investment.toNumber(),
|
||||
marketPrice: item.marketPrice,
|
||||
marketState: dataProviderResponse.marketState,
|
||||
name: symbolProfile.name,
|
||||
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
||||
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
||||
quantity: item.quantity.toNumber(),
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
@ -252,14 +251,28 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
// TODO: Add a cash position for each currency
|
||||
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
|
||||
cashDetails,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
});
|
||||
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
);
|
||||
|
||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
@ -277,6 +290,9 @@ export class PortfolioService {
|
||||
marketPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
@ -284,10 +300,13 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
@ -315,13 +334,14 @@ export class PortfolioService {
|
||||
const {
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
quantity,
|
||||
transactionCount
|
||||
} = position;
|
||||
|
||||
// Convert investment and gross performance to currency of user
|
||||
// Convert investment, gross and net performance to currency of user
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const investment = this.exchangeRateDataService.toCurrency(
|
||||
position.investment.toNumber(),
|
||||
@ -333,17 +353,31 @@ export class PortfolioService {
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||
position.netPerformance.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
[{ dataSource, symbol: aSymbol }],
|
||||
'day',
|
||||
parseISO(firstBuyDate),
|
||||
new Date()
|
||||
);
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
|
||||
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
|
||||
|
||||
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
|
||||
// Add historical entry for buy date, if no historical data available
|
||||
historicalDataArray.push({
|
||||
averagePrice: orders[0].unitPrice,
|
||||
date: firstBuyDate,
|
||||
value: orders[0].unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (historicalData[aSymbol]) {
|
||||
let j = -1;
|
||||
@ -385,19 +419,24 @@ export class PortfolioService {
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
} else {
|
||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
||||
const currentData = await this.dataProviderService.get([
|
||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||
]);
|
||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||
|
||||
let historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
'day',
|
||||
portfolioStart,
|
||||
new Date()
|
||||
@ -431,6 +470,7 @@ export class PortfolioService {
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
@ -438,6 +478,8 @@ export class PortfolioService {
|
||||
grossPerformancePercent: undefined,
|
||||
historicalData: historicalDataArray,
|
||||
investment: 0,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
quantity: 0,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
@ -449,7 +491,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -476,10 +518,16 @@ export class PortfolioService {
|
||||
const positions = currentPositions.positions.filter(
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
const dataGatheringItem = positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const symbols = positions.map((position) => position.symbol);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.get(symbols),
|
||||
this.dataProviderService.get(dataGatheringItem),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
]);
|
||||
|
||||
@ -501,6 +549,9 @@ export class PortfolioService {
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
marketState: dataProviderResponses[position.symbol].marketState,
|
||||
name: symbolProfileMap[position.symbol].name,
|
||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
position.netPerformancePercentage?.toNumber() ?? null,
|
||||
quantity: new Big(position.quantity).toNumber()
|
||||
};
|
||||
})
|
||||
@ -511,7 +562,7 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -524,6 +575,7 @@ export class PortfolioService {
|
||||
return {
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
annualizedPerformancePercent: 0,
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
@ -542,19 +594,25 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const annualizedPerformancePercent =
|
||||
currentPositions.netAnnualizedPerformance.toNumber();
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance =
|
||||
currentPositions.grossPerformance.toNumber();
|
||||
const currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage.toNumber();
|
||||
const currentNetPerformance = currentPositions.netPerformance.toNumber();
|
||||
const currentNetPerformancePercent =
|
||||
currentPositions.netPerformancePercentage.toNumber();
|
||||
|
||||
return {
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
annualizedPerformancePercent,
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
// TODO: the next two should include fees
|
||||
currentNetPerformance: currentGrossPerformance,
|
||||
currentNetPerformancePercent: currentGrossPerformancePercent,
|
||||
currentNetPerformance,
|
||||
currentNetPerformancePercent,
|
||||
currentValue: currentValue
|
||||
}
|
||||
};
|
||||
@ -577,8 +635,8 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const userId = await this.getUserId(impersonationId);
|
||||
const baseCurrency = this.request.user.Settings.currency;
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
@ -592,7 +650,7 @@ export class PortfolioService {
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
currency
|
||||
);
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
@ -605,7 +663,12 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
currency,
|
||||
userId
|
||||
);
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
@ -623,7 +686,7 @@ export class PortfolioService {
|
||||
accounts
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -644,7 +707,7 @@ export class PortfolioService {
|
||||
currentPositions
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -654,12 +717,82 @@ export class PortfolioService {
|
||||
this.getFees(orders)
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
{ baseCurrency: currency }
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({ userId });
|
||||
const fees = this.getFees(orders);
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
|
||||
const netWorth = new Big(balance)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
netWorth,
|
||||
cash: balance,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
};
|
||||
}
|
||||
|
||||
private async getCashPosition({
|
||||
cashDetails,
|
||||
investment,
|
||||
value
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashValue = new Big(cashDetails.balance);
|
||||
|
||||
return {
|
||||
allocationCurrent: cashValue.div(value).toNumber(),
|
||||
allocationInvestment: cashValue.div(investment).toNumber(),
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
currency: 'CHF',
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: cashValue.toNumber(),
|
||||
marketPrice: 0,
|
||||
marketState: MarketState.open,
|
||||
name: 'Cash',
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: ghostfolioCashSymbol,
|
||||
transactionCount: 0,
|
||||
value: cashValue.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
@ -694,10 +827,18 @@ export class PortfolioService {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
userCurrency
|
||||
)
|
||||
),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
@ -722,57 +863,83 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private getAccounts(
|
||||
private async getAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency
|
||||
userCurrency: string,
|
||||
userId: string
|
||||
) {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
for (const order of orders) {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
const currentAccounts = await this.accountService.getAccounts(userId);
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
if (ordersByAccount.length <= 0) {
|
||||
// Add account without orders
|
||||
const balance = this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
accounts[account.name] = {
|
||||
current: balance,
|
||||
original: balance
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
for (const order of ordersByAccount) {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string) {
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
aUserId
|
||||
);
|
||||
|
||||
return impersonationUserId || this.request.user.id;
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
orders: OrderWithAccount[],
|
||||
currency: Currency,
|
||||
currency: string,
|
||||
type: TypeOfOrder
|
||||
) {
|
||||
return orders
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Rule } from '../models/rule';
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
@ -10,7 +8,7 @@ export class RulesService {
|
||||
|
||||
public async evaluate<T extends RuleSettings>(
|
||||
aRules: Rule<T>[],
|
||||
aUserSettings: { baseCurrency: Currency }
|
||||
aUserSettings: { baseCurrency: string }
|
||||
) {
|
||||
return aRules
|
||||
.filter((rule) => {
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
|
@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||
exports: [SubscriptionService]
|
||||
})
|
||||
export class SubscriptionModule {}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { addDays } from 'date-fns';
|
||||
import { Subscription } from '@prisma/client';
|
||||
import { addDays, isBefore } from 'date-fns';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@Injectable()
|
||||
@ -86,4 +88,23 @@ export class SubscriptionService {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
public getSubscription(aSubscriptions: Subscription[]) {
|
||||
if (aSubscriptions.length > 0) {
|
||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
});
|
||||
|
||||
return {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface SymbolItem {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -10,7 +10,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -45,9 +47,28 @@ export class SymbolController {
|
||||
/**
|
||||
* Must be after /lookup
|
||||
*/
|
||||
@Get(':symbol')
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
||||
return this.symbolService.get(symbol);
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.symbolService.get({ dataSource, symbol });
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SymbolController } from './symbol.controller';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
||||
controllers: [SymbolController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
SymbolService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [SymbolService]
|
||||
})
|
||||
export class SymbolModule {}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -11,18 +11,22 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([aSymbol]);
|
||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
||||
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketPrice,
|
||||
currency: <Currency>(<unknown>currency)
|
||||
};
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
return {
|
||||
currency,
|
||||
marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
@ -37,16 +41,29 @@ export class SymbolService {
|
||||
results.items = items;
|
||||
|
||||
// Add custom symbols
|
||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
||||
results.items.push({
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: scraperConfiguration.name,
|
||||
symbol: scraperConfiguration.symbol
|
||||
});
|
||||
}
|
||||
});
|
||||
const ghostfolioSymbolProfiles =
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: {
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
||||
results.items.push(ghostfolioSymbolProfile);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
currency?: string;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
export interface UserSettings {
|
||||
isRestrictedView?: boolean;
|
||||
}
|
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsBoolean()
|
||||
isRestrictedView?: boolean;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingsDto {
|
||||
@IsString()
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
|
||||
@IsString()
|
||||
viewMode: ViewMode;
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -26,6 +26,8 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -78,6 +80,32 @@ export class UserController {
|
||||
};
|
||||
}
|
||||
|
||||
@Put('setting')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateUserSettings
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const userSettings: UserSettings = {
|
||||
...(<UserSettings>this.request.user.Settings.settings),
|
||||
...data
|
||||
};
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -11,9 +12,11 @@ import { UserService } from './user.service';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [ConfigurationService, PrismaService, UserService]
|
||||
providers: [ConfigurationService, PrismaService, UserService],
|
||||
exports: [UserService]
|
||||
})
|
||||
export class UserModule {}
|
||||
|
@ -1,24 +1,26 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { isBefore } from 'date-fns';
|
||||
import { Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
public static DEFAULT_CURRENCY = Currency.USD;
|
||||
public static DEFAULT_CURRENCY = 'USD';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async getUser({
|
||||
@ -50,6 +52,7 @@ export class UserService {
|
||||
}),
|
||||
accounts: Account,
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
@ -57,6 +60,10 @@ export class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||
}
|
||||
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
@ -84,6 +91,7 @@ export class UserService {
|
||||
// Set default settings if needed
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
settings: null,
|
||||
updatedAt: new Date(),
|
||||
userId: userFromDatabase?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
@ -91,24 +99,9 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (userFromDatabase?.Subscription?.length > 0) {
|
||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||
(a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
}
|
||||
);
|
||||
|
||||
user.subscription = {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
user.subscription = {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
user.subscription = this.subscriptionService.getSubscription(
|
||||
userFromDatabase?.Subscription
|
||||
);
|
||||
|
||||
if (user.subscription.type === SubscriptionType.Basic) {
|
||||
user.permissions = user.permissions.filter((permission) => {
|
||||
@ -151,9 +144,15 @@ export class UserService {
|
||||
...data,
|
||||
Account: {
|
||||
create: {
|
||||
currency: baseCurrency,
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
},
|
||||
Settings: {
|
||||
create: {
|
||||
currency: baseCurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -219,6 +218,35 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
public async updateUserSetting({
|
||||
userId,
|
||||
userSettings
|
||||
}: {
|
||||
userId: string;
|
||||
userSettings: UserSettings;
|
||||
}) {
|
||||
const settings = userSettings as Prisma.JsonObject;
|
||||
|
||||
await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
settings,
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
}
|
||||
},
|
||||
update: {
|
||||
settings
|
||||
},
|
||||
where: {
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
public async updateUserSettings({
|
||||
currency,
|
||||
userId,
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Account, Currency, SymbolProfile } from '@prisma/client';
|
||||
import { Account, SymbolProfile } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -6,7 +6,7 @@ import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
private currency: Currency;
|
||||
private currency: string;
|
||||
private fee: number;
|
||||
private date: string;
|
||||
private id: string;
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
||||
@ -29,7 +28,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
public groupCurrentPositionsByAttribute(
|
||||
positions: TimelinePosition[],
|
||||
attribute: keyof TimelinePosition,
|
||||
baseCurrency: Currency
|
||||
baseCurrency: string
|
||||
) {
|
||||
return Array.from(groupBy(attribute, positions).entries()).map(
|
||||
([attributeValue, objs]) => ({
|
||||
|
@ -1,16 +1,17 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -8,9 +11,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -7,9 +8,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Account'
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
@ -69,5 +67,5 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,5 +68,5 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -46,6 +45,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
8
apps/api/src/services/configuration.module.ts
Normal file
8
apps/api/src/services/configuration.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
providers: [ConfigurationService],
|
||||
exports: [ConfigurationService]
|
||||
})
|
||||
export class ConfigurationModule {}
|
19
apps/api/src/services/data-gathering.module.ts
Normal file
19
apps/api/src/services/data-gathering.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule
|
||||
],
|
||||
providers: [DataGatheringService],
|
||||
exports: [DataGatheringService]
|
||||
})
|
||||
export class DataGatheringModule {}
|
@ -1,15 +1,12 @@
|
||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
benchmarks,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -19,8 +16,9 @@ import {
|
||||
} from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@ -29,6 +27,7 @@ export class DataGatheringService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -38,7 +37,7 @@ export class DataGatheringService {
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
console.log('7d data gathering has been started.');
|
||||
console.time('7d-data-gathering');
|
||||
console.time('data-gathering-7d');
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
@ -71,7 +70,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('7d data gathering has been completed.');
|
||||
console.timeEnd('7d-data-gathering');
|
||||
console.timeEnd('data-gathering-7d');
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +81,7 @@ export class DataGatheringService {
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
console.log('Max data gathering has been started.');
|
||||
console.time('max-data-gathering');
|
||||
console.time('data-gathering-max');
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
@ -115,42 +114,53 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('Max data gathering has been completed.');
|
||||
console.timeEnd('max-data-gathering');
|
||||
console.timeEnd('data-gathering-max');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('profile-data-gathering');
|
||||
console.time('data-gathering-profile');
|
||||
|
||||
let symbols = aSymbols;
|
||||
let dataGatheringItems = aDataGatheringItems;
|
||||
|
||||
if (!symbols) {
|
||||
const dataGatheringItems = await this.getSymbolsProfileData();
|
||||
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
if (!dataGatheringItems) {
|
||||
dataGatheringItems = await this.getSymbolsProfileData();
|
||||
}
|
||||
|
||||
const currentData = await this.dataProviderService.get(symbols);
|
||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||
|
||||
for (const [
|
||||
symbol,
|
||||
{ assetClass, currency, dataSource, name }
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
sectors
|
||||
}
|
||||
] of Object.entries(currentData)) {
|
||||
try {
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
create: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
sectors,
|
||||
symbol
|
||||
},
|
||||
update: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
name
|
||||
name,
|
||||
sectors
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
@ -165,7 +175,7 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
console.log('Profile data gathering has been completed.');
|
||||
console.timeEnd('profile-data-gathering');
|
||||
console.timeEnd('data-gathering-profile');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
@ -207,6 +217,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
@ -230,6 +241,8 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.exchangeRateDataService.initialize();
|
||||
|
||||
if (hasError) {
|
||||
throw '';
|
||||
}
|
||||
@ -250,6 +263,34 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getIsInProgress() {
|
||||
return await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
}
|
||||
|
||||
public async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
if (lastDataGathering?.value) {
|
||||
return new Date(lastDataGathering.value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
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 {
|
||||
@ -263,7 +304,7 @@ export class DataGatheringService {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
});
|
||||
}
|
||||
|
||||
@ -273,83 +314,89 @@ export class DataGatheringService {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
symbol: true
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
.filter((distinctOrder) => {
|
||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||
})
|
||||
.map((distinctOrder) => {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
...distinctOrder,
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrdersWithDate
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
const startDate =
|
||||
(
|
||||
await this.prismaService.order.findFirst({
|
||||
orderBy: [{ date: 'asc' }]
|
||||
})
|
||||
)?.date ?? new Date();
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { dataSource: true, date: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
symbol: true
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((item) => {
|
||||
return {
|
||||
dataSource: item.dataSource,
|
||||
date: item.Order?.[0]?.date ?? startDate,
|
||||
symbol: item.symbol
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrders
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
}
|
||||
|
||||
@ -373,18 +420,13 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering = await this.getLastDataGathering();
|
||||
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
const diffInHours = differenceInHours(
|
||||
new Date(),
|
||||
new Date(lastDataGathering?.value)
|
||||
);
|
||||
const diffInHours = differenceInHours(new Date(), lastDataGathering);
|
||||
|
||||
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
|
||||
}
|
||||
|
@ -1,189 +0,0 @@
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||
private readonly yahooFinanceService: YahooFinanceService
|
||||
) {
|
||||
this.rakutenRapidApiService?.setPrisma(this.prismaService);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
if (isGhostfolioScraperApiSymbol(symbol)) {
|
||||
return this.ghostfolioScraperApiService.get(aSymbols);
|
||||
} else if (isRakutenRapidApiSymbol(symbol)) {
|
||||
return this.rakutenRapidApiService.get(aSymbols);
|
||||
}
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
||||
return (
|
||||
!isGhostfolioScraperApiSymbol(symbol) &&
|
||||
!isRakutenRapidApiSymbol(symbol)
|
||||
);
|
||||
});
|
||||
|
||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||
|
||||
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||
if (symbol) {
|
||||
const ghostfolioScraperApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isRakutenRapidApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of rakutenRapidApiSymbols) {
|
||||
if (symbol) {
|
||||
const rakutenRapidApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = rakutenRapidApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'month',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
let response: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
const granularityQuery =
|
||||
aGranularity === 'month'
|
||||
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
|
||||
: '';
|
||||
|
||||
const rangeQuery =
|
||||
from && to
|
||||
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
|
||||
to,
|
||||
DATE_FORMAT
|
||||
)}'`
|
||||
: '';
|
||||
|
||||
try {
|
||||
const queryRaw = `SELECT * FROM "MarketData" WHERE "symbol" IN ('${aSymbols.join(
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
|
||||
|
||||
const marketDataByGranularity: MarketData[] =
|
||||
await this.prismaService.$queryRaw(queryRaw);
|
||||
|
||||
response = marketDataByGranularity.reduce((r, marketData) => {
|
||||
const { date, marketPrice, symbol } = marketData;
|
||||
|
||||
r[symbol] = {
|
||||
...(r[symbol] || {}),
|
||||
[format(new Date(date), DATE_FORMAT)]: { marketPrice }
|
||||
};
|
||||
|
||||
return r;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aDataGatheringItems: IDataGatheringItem[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
const promises: Promise<{
|
||||
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||
symbol: string;
|
||||
}>[] = [];
|
||||
for (const { dataSource, symbol } of aDataGatheringItems) {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
if (dataProvider.canHandle(symbol)) {
|
||||
promises.push(
|
||||
dataProvider
|
||||
.getHistorical([symbol], undefined, from, to)
|
||||
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).search(aSymbol);
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
switch (providerName) {
|
||||
case DataSource.ALPHA_VANTAGE:
|
||||
return this.alphaVantageService;
|
||||
case DataSource.GHOSTFOLIO:
|
||||
return this.ghostfolioScraperApiService;
|
||||
case DataSource.RAKUTEN:
|
||||
return this.rakutenRapidApiService;
|
||||
case DataSource.YAHOO:
|
||||
return this.yahooFinanceService;
|
||||
default:
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user