Compare commits
173 Commits
Author | SHA1 | Date | |
---|---|---|---|
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 | |||
2d89d12d31 | |||
19e2d54791 | |||
e24e5e1c44 | |||
a0d4ff7920 | |||
099ad18aaf | |||
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@
|
|||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
/.angular/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
@ -1 +1,2 @@
|
|||||||
/dist
|
/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": ["../**/*"]
|
||||||
|
}
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
]
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
479
CHANGELOG.md
479
CHANGELOG.md
@ -5,6 +5,481 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.84.0 - 30.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
## 1.83.0 - 29.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the experimental API
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Eliminated the redundant storage of historical exchange rates
|
||||||
|
|
||||||
|
## 1.82.0 - 28.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs with routing to the admin control panel
|
||||||
|
- Added a new tab to manage historical data to the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced tabs with routing to the home page
|
||||||
|
|
||||||
|
## 1.81.0 - 27.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `12.2.4` to `13.0.2`
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
|
||||||
|
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
|
||||||
|
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
|
||||||
|
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
|
||||||
|
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the broken line charts showing value labels if openend from the allocations page
|
||||||
|
- Fixed the click event for drafts in the transactions table
|
||||||
|
|
||||||
|
## 1.80.0 - 23.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Accentuated the all time high and the all time low
|
||||||
|
|
||||||
|
## 1.79.0 - 21.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value column to the positions table
|
||||||
|
- Added support for cryptocurrency _Algorand_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Locked the symbol input in the edit transaction dialog
|
||||||
|
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
|
||||||
|
|
||||||
|
## 1.78.0 - 20.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a testimonial section to the landing page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row border of the accounts table in dark mode
|
||||||
|
|
||||||
|
## 1.77.0 - 16.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the _Get Started_ button on the registration page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row of the accounts table on mobile
|
||||||
|
- Fixed the transactions count calculation in the accounts table (exclude drafts)
|
||||||
|
|
||||||
|
## 1.76.0 - 14.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer row with buying power and net worth to the accounts table
|
||||||
|
|
||||||
|
## 1.75.0 - 13.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a logo to the log on the server start
|
||||||
|
- Added the data gathering progress to the log and the admin control panel
|
||||||
|
- Added the value column to the accounts table
|
||||||
|
|
||||||
|
## 1.74.0 - 11.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Adapted the decimal places for cryptocurrencies in the position detail dialog
|
||||||
|
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
|
||||||
|
|
||||||
|
## 1.73.0 - 10.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the info messages to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the skeleton loader of the portfolio holdings
|
||||||
|
|
||||||
|
## 1.72.0 - 08.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cached the statistics section on the about page
|
||||||
|
|
||||||
|
## 1.71.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the logger from `console.log()` to `Logger.log()`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the scraper configuration
|
||||||
|
|
||||||
|
## 1.70.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of `json` files in the import functionality for transactions
|
||||||
|
- Moved the scraper configuration to the symbol profile model
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.69.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the symbol mapping attribute to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the registration page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.68.0 - 01.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Prettified the generic scraper symbols in the portfolio proportion chart component
|
||||||
|
- Extended the statistics section on the about page by the active users count (7d)
|
||||||
|
- Extended the statistics section on the about page by the new users count
|
||||||
|
|
||||||
|
## 1.67.0 - 31.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more details to the public page (currencies, sectors, continents and regions)
|
||||||
|
- Added a `Dockerfile` and documentation to build a _Docker_ image
|
||||||
|
|
||||||
|
## 1.66.0 - 30.10.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Ordered the granted accesses by type
|
||||||
|
|
||||||
|
## 1.65.0 - 25.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user interface for granting and revoking public access to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
||||||
|
- Changed the profile data gathering from every 12 hours to once every weekend
|
||||||
|
|
||||||
|
## 1.64.0 - 21.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the data provider service to handle a dynamic list of services
|
||||||
|
|
||||||
|
## 1.63.0 - 19.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a public page to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 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 database:migrate`)
|
||||||
|
|
||||||
|
## 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 database:migrate`)
|
||||||
|
|
||||||
|
## 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 database:migrate`)
|
||||||
|
|
||||||
|
## 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 database:migrate`)
|
||||||
|
|
||||||
## 1.41.0 - 21.08.2021
|
## 1.41.0 - 21.08.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -56,7 +531,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn database:push`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.38.0 - 14.08.2021
|
## 1.38.0 - 14.08.2021
|
||||||
|
|
||||||
@ -116,7 +591,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn database:push`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.34.0 - 07.08.2021
|
## 1.34.0 - 07.08.2021
|
||||||
|
|
||||||
|
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
FROM node:14-alpine as builder
|
||||||
|
|
||||||
|
# Build application and add additional files
|
||||||
|
|
||||||
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
|
# layers when files (package.json etc.) have not changed
|
||||||
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
|
COPY ./LICENSE LICENSE
|
||||||
|
COPY ./package.json package.json
|
||||||
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
|
||||||
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
|
COPY ./angular.json angular.json
|
||||||
|
COPY ./nx.json nx.json
|
||||||
|
COPY ./replace.build.js replace.build.js
|
||||||
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
COPY ./jest.config.js jest.config.js
|
||||||
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
|
COPY ./libs libs
|
||||||
|
COPY ./apps apps
|
||||||
|
|
||||||
|
RUN yarn build:all
|
||||||
|
|
||||||
|
# Prepare the dist image with additional node_modules
|
||||||
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
|
# package.json was generated by the build process, however the original
|
||||||
|
# yarn.lock needs to be used to ensure the same versions
|
||||||
|
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
|
# all the scripts
|
||||||
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
|
# Image to run, copy everything needed from builder
|
||||||
|
FROM node:14-alpine
|
||||||
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
|
WORKDIR /ghostfolio/apps/api
|
||||||
|
EXPOSE 3333
|
||||||
|
CMD [ "node", "main" ]
|
67
README.md
67
README.md
@ -12,7 +12,7 @@
|
|||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://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>
|
||||||
<p>
|
<p>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ 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
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
@ -81,13 +81,52 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Getting Started
|
## Run with Docker
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
|
### Setup Docker Image
|
||||||
|
|
||||||
|
Run the following commands to build and start the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml build
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup Database
|
||||||
|
|
||||||
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Historical Data
|
||||||
|
|
||||||
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@ -95,27 +134,27 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
1. Run `cd docker`
|
1. Run `cd docker`
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `cd -` to go back to the project root directory
|
1. Run `cd -` to go back to the project root directory
|
||||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start server and client (see [_Development_](#Development))
|
1. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
## Development
|
### Start Server
|
||||||
|
|
||||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
|
||||||
|
|
||||||
### Start server
|
|
||||||
|
|
||||||
<ol type="a">
|
<ol type="a">
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
<li>Serve: Run <code>yarn start:server</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
### Start client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client`
|
||||||
|
|
||||||
|
### Start _Storybook_
|
||||||
|
|
||||||
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
@ -124,7 +163,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.
|
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
|
## License
|
||||||
|
|
||||||
|
117
angular.json
117
angular.json
@ -1,19 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
|
||||||
"defaultCollection": "@nrwl/nest"
|
|
||||||
},
|
|
||||||
"defaultProject": "api",
|
|
||||||
"schematics": {
|
|
||||||
"@nrwl/angular:application": {
|
|
||||||
"unitTestRunner": "jest",
|
|
||||||
"e2eTestRunner": "cypress"
|
|
||||||
},
|
|
||||||
"@nrwl/angular:library": {
|
|
||||||
"unitTestRunner": "jest"
|
|
||||||
},
|
|
||||||
"@nrwl/nest": {}
|
|
||||||
},
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
@ -32,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"generatePackageJson": true,
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"inspect": false,
|
"inspect": false,
|
||||||
@ -65,7 +52,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -96,12 +84,12 @@
|
|||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "robots.txt",
|
"glob": "robots.txt",
|
||||||
@ -197,7 +185,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
@ -217,7 +206,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
@ -238,7 +229,95 @@
|
|||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"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}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["ui"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,29 @@
|
|||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import {
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Access as AccessModel } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccessModule } from './access.module';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@Controller('access')
|
@Controller('access')
|
||||||
export class AccessController {
|
export class AccessController {
|
||||||
@ -20,13 +39,69 @@ export class AccessController {
|
|||||||
include: {
|
include: {
|
||||||
GranteeUser: true
|
GranteeUser: true
|
||||||
},
|
},
|
||||||
|
orderBy: { granteeUserId: 'asc' },
|
||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
|
if (access.GranteeUser) {
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser.alias
|
granteeAlias: access.GranteeUser?.alias,
|
||||||
|
id: access.id,
|
||||||
|
type: 'RESTRICTED_VIEW'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
granteeAlias: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createAccess(
|
||||||
|
@Body() data: CreateAccessDto
|
||||||
|
): Promise<AccessModel> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.createAccess
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.createAccess({
|
||||||
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.deleteAccess
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.deleteAccess({
|
||||||
|
id_userId: {
|
||||||
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
|||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
|
exports: [AccessService],
|
||||||
|
imports: [],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService, PrismaService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
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: {
|
public async accesses(params: {
|
||||||
include?: Prisma.AccessInclude;
|
include?: Prisma.AccessInclude;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
@ -26,4 +37,18 @@ export class AccessService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||||
|
return this.prismaService.access.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccess(
|
||||||
|
where: Prisma.AccessWhereUniqueInput
|
||||||
|
): Promise<Access> {
|
||||||
|
return this.prismaService.access.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class CreateAccessDto {}
|
@ -1,12 +1,17 @@
|
|||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import {
|
||||||
|
nullifyValuesInObject,
|
||||||
|
nullifyValuesInObjects
|
||||||
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -34,6 +39,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -85,14 +91,15 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<AccountModel[]> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accounts = await this.accountService.getAccounts(
|
let accountsWithAggregations =
|
||||||
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
impersonationUserId || this.request.user.id
|
impersonationUserId || this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -100,15 +107,23 @@ export class AccountController {
|
|||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
accounts = nullifyValuesInObjects(accounts, [
|
accountsWithAggregations = {
|
||||||
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
|
'totalBalance',
|
||||||
|
'totalValue'
|
||||||
|
]),
|
||||||
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
'balance',
|
'balance',
|
||||||
|
'convertedBalance',
|
||||||
'fee',
|
'fee',
|
||||||
'quantity',
|
'quantity',
|
||||||
'unitPrice'
|
'unitPrice',
|
||||||
]);
|
'value'
|
||||||
|
])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return accountsWithAggregations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
|
|||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AccountController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
RedisCacheModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AccountController],
|
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
export class AccountModule {}
|
export class AccountModule {}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Currency, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@ -85,7 +85,15 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
const result = { ...account, transactionCount: account.Order.length };
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const order of account.Order) {
|
||||||
|
if (!order.isDraft) {
|
||||||
|
transactionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...account, transactionCount };
|
||||||
|
|
||||||
delete result.Order;
|
delete result.Order;
|
||||||
|
|
||||||
@ -95,7 +103,7 @@ export class AccountService {
|
|||||||
|
|
||||||
public async getCashDetails(
|
public async getCashDetails(
|
||||||
aUserId: string,
|
aUserId: string,
|
||||||
aCurrency: Currency
|
aCurrency: string
|
||||||
): Promise<CashDetails> {
|
): Promise<CashDetails> {
|
||||||
let totalCashBalance = 0;
|
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';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@ -9,7 +9,7 @@ export class CreateAccountDto {
|
|||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AccountType, Currency } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@ -9,7 +9,7 @@ export class UpdateAccountDto {
|
|||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -1,21 +1,27 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AdminData,
|
||||||
|
AdminMarketData,
|
||||||
|
AdminMarketDataDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
@ -67,6 +73,29 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
@ -86,4 +115,42 @@ export class AdminController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('market-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market-data/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
@Param('symbol') symbol
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketDataBySymbol(symbol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -14,9 +16,12 @@ import { AdminService } from './admin.service';
|
|||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
PrismaModule
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService]
|
providers: [AdminService],
|
||||||
|
exports: [AdminService]
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
@ -1,68 +1,49 @@
|
|||||||
|
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
AdminData,
|
||||||
|
AdminMarketData,
|
||||||
|
AdminMarketDataDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
exchangeRates: [
|
dataGatheringProgress:
|
||||||
{
|
await this.dataGatheringService.getDataGatheringProgress(),
|
||||||
label1: Currency.EUR,
|
exchangeRates: this.exchangeRateDataService
|
||||||
label2: Currency.CHF,
|
.getCurrencies()
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
.filter((currency) => {
|
||||||
|
return currency !== baseCurrency;
|
||||||
|
})
|
||||||
|
.map((currency) => {
|
||||||
|
return {
|
||||||
|
label1: baseCurrency,
|
||||||
|
label2: currency,
|
||||||
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
Currency.EUR,
|
baseCurrency,
|
||||||
Currency.CHF
|
currency
|
||||||
)
|
)
|
||||||
},
|
};
|
||||||
{
|
}),
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
@ -70,6 +51,31 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
|
return {
|
||||||
|
marketData: await (
|
||||||
|
await this.dataGatheringService.getSymbolsMax()
|
||||||
|
).map((symbol) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
return {
|
||||||
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
symbol: aSymbol
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
private async getLastDataGathering() {
|
||||||
const lastDataGathering =
|
const lastDataGathering =
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
await this.dataGatheringService.getLastDataGathering();
|
||||||
@ -85,7 +91,7 @@ export class AdminService {
|
|||||||
return 'IN_PROGRESS';
|
return 'IN_PROGRESS';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
@ -107,7 +113,8 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true
|
id: true,
|
||||||
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30,
|
||||||
where: {
|
where: {
|
||||||
@ -118,16 +125,23 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, alias, Analytics, createdAt, id }) => {
|
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
|
|
||||||
|
const subscription = this.configurationService.get(
|
||||||
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
|
)
|
||||||
|
? this.subscriptionService.getSubscription(Subscription)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
alias,
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
lastActivity: Analytics.updatedAt,
|
lastActivity: Analytics.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
|
@ -19,7 +19,6 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExperimentalModule } from './experimental/experimental.module';
|
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
@ -42,7 +41,6 @@ import { UserModule } from './user/user.module';
|
|||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExperimentalModule,
|
|
||||||
ExportModule,
|
ExportModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
@ -62,10 +62,10 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-attestation-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async generateAttestationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateAttestationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
})
|
}),
|
||||||
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error(err);
|
Logger.error(error);
|
||||||
done(err, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,25 +2,26 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException
|
InternalServerErrorException,
|
||||||
|
Logger
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import {
|
import {
|
||||||
GenerateAssertionOptionsOpts,
|
GenerateAuthenticationOptionsOpts,
|
||||||
GenerateAttestationOptionsOpts,
|
GenerateRegistrationOptionsOpts,
|
||||||
VerifiedAssertion,
|
VerifiedAuthenticationResponse,
|
||||||
VerifiedAttestation,
|
VerifiedRegistrationResponse,
|
||||||
VerifyAssertionResponseOpts,
|
VerifyAuthenticationResponseOpts,
|
||||||
VerifyAttestationResponseOpts,
|
VerifyRegistrationResponseOpts,
|
||||||
generateAssertionOptions,
|
generateAuthenticationOptions,
|
||||||
generateAttestationOptions,
|
generateRegistrationOptions,
|
||||||
verifyAssertionResponse,
|
verifyAuthenticationResponse,
|
||||||
verifyAttestationResponse
|
verifyRegistrationResponse
|
||||||
} from '@simplewebauthn/server';
|
} from '@simplewebauthn/server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -46,10 +47,10 @@ export class WebAuthService {
|
|||||||
return this.configurationService.get('ROOT_URL');
|
return this.configurationService.get('ROOT_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async generateAttestationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
|
|
||||||
const opts: GenerateAttestationOptionsOpts = {
|
const opts: GenerateRegistrationOptionsOpts = {
|
||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
@ -63,7 +64,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAttestationOptions(opts);
|
const options = generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -84,27 +85,27 @@ export class WebAuthService {
|
|||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
const expectedChallenge = user.authChallenge;
|
const expectedChallenge = user.authChallenge;
|
||||||
|
|
||||||
let verification: VerifiedAttestation;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAttestationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
credential,
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID
|
||||||
};
|
};
|
||||||
verification = await verifyAttestationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, attestationInfo } = verification;
|
const { registrationInfo, verified } = verification;
|
||||||
|
|
||||||
const devices = await this.deviceService.authDevices({
|
const devices = await this.deviceService.authDevices({
|
||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
});
|
});
|
||||||
if (verified && attestationInfo) {
|
if (registrationInfo && verified) {
|
||||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||||
|
|
||||||
let existingDevice = devices.find(
|
let existingDevice = devices.find(
|
||||||
(device) => device.credentialId === credentialID
|
(device) => device.credentialId === credentialID
|
||||||
@ -115,9 +116,9 @@ export class WebAuthService {
|
|||||||
* Add the returned device to the user's list of devices
|
* Add the returned device to the user's list of devices
|
||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
|
counter,
|
||||||
credentialPublicKey,
|
credentialPublicKey,
|
||||||
credentialId: credentialID,
|
credentialId: credentialID,
|
||||||
counter,
|
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -138,20 +139,20 @@ export class WebAuthService {
|
|||||||
throw new Error('Device not found');
|
throw new Error('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts: GenerateAssertionOptionsOpts = {
|
const opts: GenerateAuthenticationOptionsOpts = {
|
||||||
timeout: 60000,
|
|
||||||
allowCredentials: [
|
allowCredentials: [
|
||||||
{
|
{
|
||||||
id: device.credentialId,
|
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({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -177,29 +178,29 @@ export class WebAuthService {
|
|||||||
|
|
||||||
const user = await this.userService.user({ id: device.userId });
|
const user = await this.userService.user({ id: device.userId });
|
||||||
|
|
||||||
let verification: VerifiedAssertion;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAssertionResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
credential,
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
|
||||||
expectedOrigin: this.expectedOrigin,
|
|
||||||
expectedRPID: this.rpID,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
counter: device.counter
|
counter: device.counter
|
||||||
}
|
},
|
||||||
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
|
expectedOrigin: this.expectedOrigin,
|
||||||
|
expectedRPID: this.rpID
|
||||||
};
|
};
|
||||||
verification = verifyAssertionResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, assertionInfo } = verification;
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
device.counter = assertionInfo.newCounter;
|
device.counter = authenticationInfo.newCounter;
|
||||||
|
|
||||||
await this.deviceService.updateAuthDevice({
|
await this.deviceService.updateAuthDevice({
|
||||||
data: device,
|
data: device,
|
||||||
|
2
apps/api/src/app/cache/cache.controller.ts
vendored
2
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
24
apps/api/src/app/cache/cache.module.ts
vendored
24
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,30 +1,30 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisCacheModule],
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
|
||||||
CacheService,
|
CacheService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataGatheringService,
|
DataGatheringService,
|
||||||
DataProviderService,
|
PrismaService
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
import { Currency, Type } from '@prisma/client';
|
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateOrderDto {
|
|
||||||
@IsString()
|
|
||||||
currency: Currency;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
date: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
quantity: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
symbol: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
type: Type;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
unitPrice: number;
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { parse } from 'date-fns';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
import { Data } from './interfaces/data.interface';
|
|
||||||
|
|
||||||
@Controller('experimental')
|
|
||||||
export class ExperimentalController {
|
|
||||||
public constructor(
|
|
||||||
private readonly experimentalService: ExperimentalService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('benchmarks')
|
|
||||||
public async getBenchmarks(
|
|
||||||
@Headers('Authorization') apiToken: string
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarks.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('benchmarks/:symbol')
|
|
||||||
public async getBenchmark(
|
|
||||||
@Headers('Authorization') apiToken: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<{ date: Date; marketPrice: number }[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketData = await this.experimentalService.getBenchmark(symbol);
|
|
||||||
|
|
||||||
if (marketData?.length === 0) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return marketData;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ExperimentalController } from './experimental.controller';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
PrismaModule
|
|
||||||
],
|
|
||||||
controllers: [ExperimentalController],
|
|
||||||
providers: [AccountService, ExperimentalService]
|
|
||||||
})
|
|
||||||
export class ExperimentalModule {}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ExperimentalService {
|
|
||||||
public constructor(
|
|
||||||
private readonly accountService: AccountService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getBenchmark(aSymbol: string) {
|
|
||||||
return this.prismaService.marketData.findMany({
|
|
||||||
orderBy: { date: 'asc' },
|
|
||||||
select: { date: true, marketPrice: true },
|
|
||||||
where: { symbol: aSymbol }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface Data {
|
|
||||||
currency: Currency;
|
|
||||||
value: number;
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import { Export } from '@ghostfolio/common/interfaces';
|
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 { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -18,6 +17,6 @@ import { ExportService } from './export.service';
|
|||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
providers: [CacheService, ExportService]
|
providers: [ExportService]
|
||||||
})
|
})
|
||||||
export class ExportModule {}
|
export class ExportModule {}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
import { IsArray } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
orders: Partial<Order>[];
|
@Type(() => CreateOrderDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
orders: Order[];
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -39,10 +40,13 @@ export class ImportController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
{
|
||||||
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
message: [error.message]
|
||||||
|
},
|
||||||
StatusCodes.BAD_REQUEST
|
StatusCodes.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
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({
|
public async import({
|
||||||
orders,
|
orders,
|
||||||
@ -14,7 +20,10 @@ export class ImportService {
|
|||||||
orders: Partial<Order>[];
|
orders: Partial<Order>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
await this.validateOrders({ orders, userId });
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
|
accountId,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -25,6 +34,11 @@ export class ImportService {
|
|||||||
unitPrice
|
unitPrice
|
||||||
} of orders) {
|
} of orders) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { userId, id: accountId }
|
||||||
|
}
|
||||||
|
},
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
fee,
|
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,11 +1,11 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -14,22 +14,22 @@ import { InfoService } from './info.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
DataGatheringService,
|
DataGatheringService,
|
||||||
DataProviderService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
InfoService,
|
InfoService,
|
||||||
PrismaService,
|
PrismaService
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InfoModule {}
|
export class InfoModule {}
|
||||||
|
@ -1,24 +1,31 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Currency } from '@prisma/client';
|
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -56,9 +63,10 @@ export class InfoService {
|
|||||||
...info,
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
platforms,
|
platforms,
|
||||||
currencies: Object.values(Currency),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
|
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions()
|
||||||
};
|
};
|
||||||
@ -90,6 +98,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) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
@ -105,12 +134,34 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countNewUsers(aDays: number) {
|
||||||
|
return await this.prismaService.user.count({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
gt: subDays(new Date(), aDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: InfoService.DEMO_USER_ID
|
||||||
@ -129,15 +180,40 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statistics: Statistics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statistics = JSON.parse(
|
||||||
|
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statistics) {
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
|
const activeUsers7d = await this.countActiveUsers(7);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
|
||||||
return {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
|
activeUsers7d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubStargazers
|
gitHubContributors,
|
||||||
|
gitHubStargazers,
|
||||||
|
newUsers30d
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
InfoService.CACHE_KEY_STATISTICS,
|
||||||
|
JSON.stringify(statistics)
|
||||||
|
);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<Subscription[]> {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Currency, DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@ -23,7 +23,7 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
@ -56,7 +56,9 @@ export class OrderService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
this.dataGatheringService.gatherProfileData([
|
||||||
|
{ dataSource: data.dataSource, symbol: data.symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
await this.cacheService.flush();
|
await this.cacheService.flush();
|
||||||
|
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Currency, DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { Currency, MarketData } from '@prisma/client';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
|
|
||||||
jest.mock('./market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
MarketDataService: jest.fn().mockImplementation(() => {
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
|
|||||||
date,
|
date,
|
||||||
symbol,
|
symbol,
|
||||||
createdAt: date,
|
createdAt: date,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
marketPrice: 1847.839966
|
marketPrice: 1847.839966
|
||||||
});
|
});
|
||||||
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
|
|||||||
return Promise.resolve<MarketData[]>([
|
return Promise.resolve<MarketData[]>([
|
||||||
{
|
{
|
||||||
createdAt: dateRangeStart,
|
createdAt: dateRangeStart,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
createdAt: dateRangeEnd,
|
createdAt: dateRangeEnd,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
@ -69,15 +72,8 @@ describe('CurrentRateService', () => {
|
|||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
null,
|
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
await exchangeRateDataService.initialize();
|
||||||
@ -92,10 +88,10 @@ describe('CurrentRateService', () => {
|
|||||||
it('getValue', async () => {
|
it('getValue', async () => {
|
||||||
expect(
|
expect(
|
||||||
await currentRateService.getValue({
|
await currentRateService.getValue({
|
||||||
currency: Currency.USD,
|
currency: 'USD',
|
||||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||||
symbol: 'AMZN',
|
symbol: 'AMZN',
|
||||||
userCurrency: Currency.CHF
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
marketPrice: 1847.839966
|
marketPrice: 1847.839966
|
||||||
@ -105,13 +101,13 @@ describe('CurrentRateService', () => {
|
|||||||
it('getValues', async () => {
|
it('getValues', async () => {
|
||||||
expect(
|
expect(
|
||||||
await currentRateService.getValues({
|
await currentRateService.getValues({
|
||||||
currencies: { AMZN: Currency.USD },
|
currencies: { AMZN: 'USD' },
|
||||||
|
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||||
},
|
},
|
||||||
symbols: ['AMZN'],
|
userCurrency: 'CHF'
|
||||||
userCurrency: Currency.CHF
|
|
||||||
})
|
})
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
@ -8,7 +9,6 @@ import { flatten } from 'lodash';
|
|||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrentRateService {
|
export class CurrentRateService {
|
||||||
@ -25,11 +25,16 @@ export class CurrentRateService {
|
|||||||
userCurrency
|
userCurrency
|
||||||
}: GetValueParams): Promise<GetValueObject> {
|
}: GetValueParams): Promise<GetValueObject> {
|
||||||
if (isToday(date)) {
|
if (isToday(date)) {
|
||||||
const dataProviderResult = await this.dataProviderService.get([symbol]);
|
const dataProviderResult = await this.dataProviderService.get([
|
||||||
|
{
|
||||||
|
symbol,
|
||||||
|
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||||
|
}
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
|
symbol,
|
||||||
date: resetHours(date),
|
date: resetHours(date),
|
||||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||||
symbol: symbol
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,8 +60,8 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
public async getValues({
|
public async getValues({
|
||||||
currencies,
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
symbols,
|
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||||
const includeToday =
|
const includeToday =
|
||||||
@ -75,17 +80,20 @@ export class CurrentRateService {
|
|||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService.get(symbols).then((dataResultProvider) => {
|
this.dataProviderService
|
||||||
|
.get(dataGatheringItems)
|
||||||
|
.then((dataResultProvider) => {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const symbol of symbols) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
symbol,
|
|
||||||
date: today,
|
date: today,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
dataResultProvider?.[symbol]?.marketPrice ?? 0,
|
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
||||||
dataResultProvider?.[symbol]?.currency,
|
0,
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
),
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@ -93,6 +101,10 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.marketDataService
|
this.marketDataService
|
||||||
.getRange({
|
.getRange({
|
||||||
|
@ -6,6 +6,9 @@ export interface CurrentPositions {
|
|||||||
positions: TimelinePosition[];
|
positions: TimelinePosition[];
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
|
netAnnualizedPerformance: Big;
|
||||||
|
netPerformance: Big;
|
||||||
|
netPerformancePercentage: Big;
|
||||||
currentValue: Big;
|
currentValue: Big;
|
||||||
totalInvestment: Big;
|
totalInvestment: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface GetValueParams {
|
export interface GetValueParams {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
userCurrency: Currency;
|
userCurrency: string;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Currency } from '@prisma/client';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
import { DateQuery } from './date-query.interface';
|
import { DateQuery } from './date-query.interface';
|
||||||
|
|
||||||
export interface GetValuesParams {
|
export interface GetValuesParams {
|
||||||
currencies: { [symbol: string]: Currency };
|
currencies: { [symbol: string]: string };
|
||||||
|
dataGatheringItems: IDataGatheringItem[];
|
||||||
dateQuery: DateQuery;
|
dateQuery: DateQuery;
|
||||||
symbols: string[];
|
userCurrency: string;
|
||||||
userCurrency: Currency;
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
import { Currency } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
fee: Big;
|
||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { Currency } from '@prisma/client';
|
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: Currency;
|
currency: string;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
@ -11,9 +13,19 @@ export interface PortfolioPositionDetail {
|
|||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
|
name: string;
|
||||||
|
netPerformance: number;
|
||||||
|
netPerformancePercent: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalDataContainer {
|
||||||
|
isAllTimeHigh: boolean;
|
||||||
|
isAllTimeLow: boolean;
|
||||||
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
export interface HistoricalDataItem {
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TimelineInfoInterface {
|
||||||
|
maxNetPerformance: Big;
|
||||||
|
minNetPerformance: Big;
|
||||||
|
timelinePeriods: TimelinePeriod[];
|
||||||
|
}
|
@ -4,5 +4,6 @@ export interface TimelinePeriod {
|
|||||||
date: string;
|
date: string;
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
|
netPerformance: Big;
|
||||||
value: Big;
|
value: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { Currency } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
fee: Big;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,15 @@
|
|||||||
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Logger } from '@nestjs/common';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
addMonths,
|
addMonths,
|
||||||
addYears,
|
addYears,
|
||||||
|
differenceInDays,
|
||||||
endOfDay,
|
endOfDay,
|
||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
@ -14,7 +17,7 @@ import {
|
|||||||
max,
|
max,
|
||||||
min
|
min
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten, isNumber } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
@ -33,7 +36,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private currentRateService: CurrentRateService,
|
private currentRateService: CurrentRateService,
|
||||||
private currency: Currency
|
private currency: string
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||||
@ -58,6 +61,8 @@ export class PortfolioCalculator {
|
|||||||
.plus(oldAccumulatedSymbol.quantity);
|
.plus(oldAccumulatedSymbol.quantity);
|
||||||
currentTransactionPointItem = {
|
currentTransactionPointItem = {
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
investment: newQuantity.eq(0)
|
investment: newQuantity.eq(0)
|
||||||
? new Big(0)
|
? new Big(0)
|
||||||
@ -72,6 +77,8 @@ export class PortfolioCalculator {
|
|||||||
} else {
|
} else {
|
||||||
currentTransactionPointItem = {
|
currentTransactionPointItem = {
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee,
|
||||||
firstBuyDate: order.date,
|
firstBuyDate: order.date,
|
||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
quantity: order.quantity.mul(factor),
|
quantity: order.quantity.mul(factor),
|
||||||
@ -101,6 +108,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[] {
|
public getTransactionPoints(): TransactionPoint[] {
|
||||||
return this.transactionPoints;
|
return this.transactionPoints;
|
||||||
}
|
}
|
||||||
@ -112,11 +136,14 @@ export class PortfolioCalculator {
|
|||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||||
if (!this.transactionPoints?.length) {
|
if (!this.transactionPoints?.length) {
|
||||||
return {
|
return {
|
||||||
|
currentValue: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
positions: [],
|
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: 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)
|
totalInvestment: new Big(0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -130,12 +157,15 @@ export class PortfolioCalculator {
|
|||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = this.transactionPoints.length;
|
let firstIndex = this.transactionPoints.length;
|
||||||
const dates = [];
|
const dates = [];
|
||||||
const symbols = new Set<string>();
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: Currency } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
dates.push(resetHours(start));
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
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;
|
currencies[item.symbol] = item.currency;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||||
@ -155,10 +185,10 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const marketSymbols = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
in: dates
|
in: dates
|
||||||
},
|
},
|
||||||
symbols: Array.from(symbols),
|
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -181,7 +211,9 @@ export class PortfolioCalculator {
|
|||||||
const startString = format(start, DATE_FORMAT);
|
const startString = format(start, DATE_FORMAT);
|
||||||
|
|
||||||
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||||
|
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
|
||||||
const grossPerformance: { [symbol: string]: Big } = {};
|
const grossPerformance: { [symbol: string]: Big } = {};
|
||||||
|
const netPerformance: { [symbol: string]: Big } = {};
|
||||||
const todayString = format(today, DATE_FORMAT);
|
const todayString = format(today, DATE_FORMAT);
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
if (firstIndex > 0) {
|
||||||
@ -190,6 +222,7 @@ export class PortfolioCalculator {
|
|||||||
const invalidSymbols = [];
|
const invalidSymbols = [];
|
||||||
const lastInvestments: { [symbol: string]: Big } = {};
|
const lastInvestments: { [symbol: string]: Big } = {};
|
||||||
const lastQuantities: { [symbol: string]: Big } = {};
|
const lastQuantities: { [symbol: string]: Big } = {};
|
||||||
|
const lastFees: { [symbol: string]: Big } = {};
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
const initialValues: { [symbol: string]: Big } = {};
|
||||||
|
|
||||||
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||||
@ -202,14 +235,10 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
const items = this.transactionPoints[i].items;
|
const items = this.transactionPoints[i].items;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
|
|
||||||
if (!oldHoldingPeriodReturn) {
|
|
||||||
oldHoldingPeriodReturn = new Big(1);
|
|
||||||
}
|
|
||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -224,6 +253,13 @@ export class PortfolioCalculator {
|
|||||||
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||||
let initialValue = itemValue?.mul(lastQuantity);
|
let initialValue = itemValue?.mul(lastQuantity);
|
||||||
let investedValue = itemValue?.mul(item.quantity);
|
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))) {
|
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||||
initialValue = item.investment;
|
initialValue = item.investment;
|
||||||
investedValue = item.investment;
|
investedValue = item.investment;
|
||||||
@ -235,7 +271,7 @@ export class PortfolioCalculator {
|
|||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -247,18 +283,26 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||||
holdingPeriodReturns[item.symbol] =
|
holdingPeriodReturns[item.symbol] = (
|
||||||
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
|
holdingPeriodReturns[item.symbol] ?? new Big(1)
|
||||||
let oldGrossPerformance = grossPerformance[item.symbol];
|
).mul(holdingPeriodReturn);
|
||||||
if (!oldGrossPerformance) {
|
grossPerformance[item.symbol] = (
|
||||||
oldGrossPerformance = new Big(0);
|
grossPerformance[item.symbol] ?? new Big(0)
|
||||||
}
|
).plus(endValue.minus(investedValue));
|
||||||
const currentPerformance = endValue.minus(investedValue);
|
|
||||||
grossPerformance[item.symbol] =
|
const netHoldingPeriodReturn = endValue.div(
|
||||||
oldGrossPerformance.plus(currentPerformance);
|
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;
|
lastInvestments[item.symbol] = item.investment;
|
||||||
lastQuantities[item.symbol] = item.quantity;
|
lastQuantities[item.symbol] = item.quantity;
|
||||||
|
lastFees[item.symbol] = item.fee;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +316,7 @@ export class PortfolioCalculator {
|
|||||||
? new Big(0)
|
? new Big(0)
|
||||||
: item.investment.div(item.quantity),
|
: item.investment.div(item.quantity),
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
|
dataSource: item.dataSource,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: isValid
|
grossPerformance: isValid
|
||||||
? grossPerformance[item.symbol] ?? null
|
? grossPerformance[item.symbol] ?? null
|
||||||
@ -282,15 +327,17 @@ export class PortfolioCalculator {
|
|||||||
: null,
|
: null,
|
||||||
investment: item.investment,
|
investment: item.investment,
|
||||||
marketPrice: marketValue?.toNumber() ?? null,
|
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,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const overall = this.calculateOverallGrossPerformance(
|
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||||
positions,
|
|
||||||
initialValues
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...overall,
|
...overall,
|
||||||
@ -319,16 +366,20 @@ export class PortfolioCalculator {
|
|||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
timelineSpecification: TimelineSpecification[],
|
timelineSpecification: TimelineSpecification[],
|
||||||
endDate: string
|
endDate: string
|
||||||
): Promise<TimelinePeriod[]> {
|
): Promise<TimelineInfoInterface> {
|
||||||
if (timelineSpecification.length === 0) {
|
if (timelineSpecification.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
maxNetPerformance: new Big(0),
|
||||||
|
minNetPerformance: new Big(0),
|
||||||
|
timelinePeriods: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = timelineSpecification[0].start;
|
const startDate = timelineSpecification[0].start;
|
||||||
const start = parseDate(startDate);
|
const start = parseDate(startDate);
|
||||||
const end = parseDate(endDate);
|
const end = parseDate(endDate);
|
||||||
|
|
||||||
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
|
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let j = -1;
|
let j = -1;
|
||||||
for (
|
for (
|
||||||
@ -371,14 +422,43 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
timelinePeriodPromises
|
timelinePeriodPromises
|
||||||
);
|
);
|
||||||
|
const minNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((minPerformance, current) => {
|
||||||
|
if (minPerformance.lt(current)) {
|
||||||
|
return minPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return flatten(timelinePeriods);
|
const maxNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((maxPerformance, current) => {
|
||||||
|
if (maxPerformance.gt(current)) {
|
||||||
|
return maxPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelinePeriods = timelineInfoInterfaces.map(
|
||||||
|
(timelineInfo) => timelineInfo.timelinePeriods
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: flatten(timelinePeriods)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateOverallGrossPerformance(
|
private calculateOverallPerformance(
|
||||||
positions: TimelinePosition[],
|
positions: TimelinePosition[],
|
||||||
initialValues: { [p: string]: Big }
|
initialValues: { [p: string]: Big }
|
||||||
) {
|
) {
|
||||||
@ -387,7 +467,14 @@ export class PortfolioCalculator {
|
|||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
let grossPerformance = new Big(0);
|
let grossPerformance = new Big(0);
|
||||||
let grossPerformancePercentage = new Big(0);
|
let grossPerformancePercentage = new Big(0);
|
||||||
|
let netPerformance = new Big(0);
|
||||||
|
let netPerformancePercentage = new Big(0);
|
||||||
let completeInitialValue = 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) {
|
for (const currentPosition of positions) {
|
||||||
if (currentPosition.marketPrice) {
|
if (currentPosition.marketPrice) {
|
||||||
currentValue = currentValue.add(
|
currentValue = currentValue.add(
|
||||||
@ -401,6 +488,7 @@ export class PortfolioCalculator {
|
|||||||
grossPerformance = grossPerformance.plus(
|
grossPerformance = grossPerformance.plus(
|
||||||
currentPosition.grossPerformance
|
currentPosition.grossPerformance
|
||||||
);
|
);
|
||||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
@ -414,9 +502,21 @@ export class PortfolioCalculator {
|
|||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
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)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
@ -425,6 +525,10 @@ export class PortfolioCalculator {
|
|||||||
if (!completeInitialValue.eq(0)) {
|
if (!completeInitialValue.eq(0)) {
|
||||||
grossPerformancePercentage =
|
grossPerformancePercentage =
|
||||||
grossPerformancePercentage.div(completeInitialValue);
|
grossPerformancePercentage.div(completeInitialValue);
|
||||||
|
netPerformancePercentage =
|
||||||
|
netPerformancePercentage.div(completeInitialValue);
|
||||||
|
netAnnualizedPerformance =
|
||||||
|
netAnnualizedPerformance.div(completeInitialValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -432,6 +536,9 @@ export class PortfolioCalculator {
|
|||||||
grossPerformance,
|
grossPerformance,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
|
netAnnualizedPerformance,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
totalInvestment
|
totalInvestment
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -440,36 +547,41 @@ export class PortfolioCalculator {
|
|||||||
j: number,
|
j: number,
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date
|
endDate: Date
|
||||||
): Promise<TimelinePeriod[]> {
|
): Promise<TimelineInfoInterface> {
|
||||||
let investment: Big = new Big(0);
|
let investment: Big = new Big(0);
|
||||||
|
let fees: Big = new Big(0);
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
} = {};
|
} = {};
|
||||||
if (j >= 0) {
|
if (j >= 0) {
|
||||||
const currencies: { [name: string]: Currency } = {};
|
const currencies: { [name: string]: string } = {};
|
||||||
const symbols: string[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
for (const item of this.transactionPoints[j].items) {
|
||||||
currencies[item.symbol] = item.currency;
|
currencies[item.symbol] = item.currency;
|
||||||
symbols.push(item.symbol);
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
investment = investment.add(item.investment);
|
investment = investment.add(item.investment);
|
||||||
|
fees = fees.add(item.fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
if (symbols.length > 0) {
|
if (dataGatheringItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
marketSymbols = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
gte: startDate,
|
gte: startDate,
|
||||||
lt: endOfDay(endDate)
|
lt: endOfDay(endDate)
|
||||||
},
|
},
|
||||||
symbols,
|
|
||||||
currencies,
|
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
@ -490,7 +602,9 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results: TimelinePeriod[] = [];
|
||||||
|
let maxNetPerformance: Big = null;
|
||||||
|
let minNetPerformance: Big = null;
|
||||||
for (
|
for (
|
||||||
let currentDate = startDate;
|
let currentDate = startDate;
|
||||||
isBefore(currentDate, endDate);
|
isBefore(currentDate, endDate);
|
||||||
@ -513,17 +627,37 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!invalid) {
|
if (!invalid) {
|
||||||
|
const grossPerformance = value.minus(investment);
|
||||||
|
const netPerformance = grossPerformance.minus(fees);
|
||||||
|
if (
|
||||||
|
minNetPerformance === null ||
|
||||||
|
minNetPerformance.gt(netPerformance)
|
||||||
|
) {
|
||||||
|
minNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxNetPerformance === null ||
|
||||||
|
maxNetPerformance.lt(netPerformance)
|
||||||
|
) {
|
||||||
|
maxNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
date: currentDateAsString,
|
grossPerformance,
|
||||||
grossPerformance: value.minus(investment),
|
|
||||||
investment,
|
investment,
|
||||||
value
|
netPerformance,
|
||||||
|
value,
|
||||||
|
date: currentDateAsString
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: results
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFactor(type: OrderType) {
|
private getFactor(type: OrderType) {
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -28,16 +33,15 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import {
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
HistoricalDataItem,
|
|
||||||
PortfolioPositionDetail
|
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
@ -47,8 +51,17 @@ export class PortfolioController {
|
|||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async findAll(
|
public async findAll(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Res() res: Response
|
||||||
): Promise<InvestmentItem[]> {
|
): 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(
|
let investments = await this.portfolioService.getInvestments(
|
||||||
impersonationId
|
impersonationId
|
||||||
);
|
);
|
||||||
@ -68,7 +81,7 @@ export class PortfolioController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return investments;
|
return <any>res.json(investments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@ -77,12 +90,14 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<PortfolioChart> {
|
||||||
let chartData = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasNullValue = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
@ -115,7 +130,11 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(chartData);
|
return <any>res.json({
|
||||||
|
chart: chartData,
|
||||||
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@ -125,8 +144,20 @@ export class PortfolioController {
|
|||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioDetails> {
|
): 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: {} });
|
||||||
|
}
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(impersonationId, range);
|
await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
@ -156,8 +187,9 @@ export class PortfolioController {
|
|||||||
portfolioPosition.grossPerformance = null;
|
portfolioPosition.grossPerformance = null;
|
||||||
portfolioPosition.investment =
|
portfolioPosition.investment =
|
||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
|
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
@ -223,6 +255,7 @@ export class PortfolioController {
|
|||||||
return nullifyValuesInObject(position, [
|
return nullifyValuesInObject(position, [
|
||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
|
'netPerformance',
|
||||||
'quantity'
|
'quantity'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -231,6 +264,65 @@ export class PortfolioController {
|
|||||||
return <any>res.json(result);
|
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 });
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND);
|
||||||
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDetails = true;
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
|
access.userId,
|
||||||
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
|
hasDetails,
|
||||||
|
holdings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: portfolioPosition.currency,
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(portfolioPublicDetails);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getSummary(
|
public async getSummary(
|
||||||
@ -246,6 +338,7 @@ export class PortfolioController {
|
|||||||
'cash',
|
'cash',
|
||||||
'committedFunds',
|
'committedFunds',
|
||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'fees',
|
'fees',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
@ -276,7 +369,9 @@ export class PortfolioController {
|
|||||||
position = nullifyValuesInObject(position, [
|
position = nullifyValuesInObject(position, [
|
||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'quantity'
|
'netPerformance',
|
||||||
|
'quantity',
|
||||||
|
'value'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,8 +387,19 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Res() res: Response
|
||||||
): Promise<PortfolioReport> {
|
): 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,3 +1,4 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
@ -6,35 +7,37 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
MarketDataService,
|
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
RulesService,
|
RulesService
|
||||||
SymbolProfileService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PortfolioModule {}
|
export class PortfolioModule {}
|
||||||
|
@ -21,31 +21,31 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
import {
|
||||||
|
UNKNOWN_KEY,
|
||||||
|
baseCurrency,
|
||||||
|
ghostfolioCashSymbol
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Accounts,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPosition,
|
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
Position,
|
Position,
|
||||||
TimelinePosition
|
TimelinePosition
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import type {
|
||||||
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import {
|
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
AssetClass,
|
|
||||||
Currency,
|
|
||||||
DataSource,
|
|
||||||
Type as TypeOfOrder
|
|
||||||
} from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
endOfToday,
|
endOfToday,
|
||||||
@ -56,12 +56,14 @@ import {
|
|||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
setDayOfYear,
|
setDayOfYear,
|
||||||
|
startOfDay,
|
||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
HistoricalDataContainer,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
@ -81,10 +83,63 @@ export class PortfolioService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
||||||
|
const [accounts, details] = await Promise.all([
|
||||||
|
this.accountService.accounts({
|
||||||
|
include: { Order: true, Platform: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
where: { userId: aUserId }
|
||||||
|
}),
|
||||||
|
this.getDetails(aUserId, aUserId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const order of account.Order) {
|
||||||
|
if (!order.isDraft) {
|
||||||
|
transactionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...account,
|
||||||
|
transactionCount,
|
||||||
|
convertedBalance: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
value: details.accounts[account.name]?.current ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
delete result.Order;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
||||||
|
const accounts = await this.getAccounts(aUserId);
|
||||||
|
let totalBalance = 0;
|
||||||
|
let totalValue = 0;
|
||||||
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
totalBalance += account.convertedBalance;
|
||||||
|
totalValue += account.value;
|
||||||
|
transactionCount += account.transactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accounts, totalBalance, totalValue, transactionCount };
|
||||||
|
}
|
||||||
|
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -111,8 +166,8 @@ export class PortfolioService {
|
|||||||
public async getChart(
|
public async getChart(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataContainer> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -122,14 +177,21 @@ export class PortfolioService {
|
|||||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let portfolioStart = parse(
|
let portfolioStart = parse(
|
||||||
transactionPoints[0].date,
|
transactionPoints[0].date,
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
|
|
||||||
|
// Get start date for the full portfolio because of because of the
|
||||||
|
// min and max calculation
|
||||||
|
portfolioStart = this.getStartDate('max', portfolioStart);
|
||||||
|
|
||||||
const timelineSpecification: TimelineSpecification[] = [
|
const timelineSpecification: TimelineSpecification[] = [
|
||||||
{
|
{
|
||||||
@ -138,27 +200,62 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeline = await portfolioCalculator.calculateTimeline(
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
timelineSpecification,
|
timelineSpecification,
|
||||||
format(new Date(), DATE_FORMAT)
|
format(new Date(), DATE_FORMAT)
|
||||||
);
|
);
|
||||||
|
|
||||||
return timeline
|
const timeline = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
|
const items = timeline
|
||||||
.filter((timelineItem) => timelineItem !== null)
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.map((timelineItem) => ({
|
.map((timelineItem) => ({
|
||||||
date: timelineItem.date,
|
date: timelineItem.date,
|
||||||
marketPrice: timelineItem.value,
|
marketPrice: timelineItem.value,
|
||||||
value: timelineItem.grossPerformance.toNumber()
|
value: timelineItem.netPerformance.toNumber()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let lastItem = null;
|
||||||
|
if (timeline.length > 0) {
|
||||||
|
lastItem = timeline[timeline.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
||||||
|
lastItem?.netPerformance
|
||||||
|
);
|
||||||
|
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
||||||
|
lastItem?.netPerformance
|
||||||
|
);
|
||||||
|
if (isAllTimeHigh && isAllTimeLow) {
|
||||||
|
isAllTimeHigh = false;
|
||||||
|
isAllTimeLow = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
portfolioStart = startOfDay(
|
||||||
|
this.getStartDate(
|
||||||
|
aDateRange,
|
||||||
|
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAllTimeHigh,
|
||||||
|
isAllTimeLow,
|
||||||
|
items: items.filter((item) => {
|
||||||
|
// Filter items of date range
|
||||||
|
return !isAfter(portfolioStart, parseDate(item.date));
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
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(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
userCurrency
|
userCurrency
|
||||||
@ -191,12 +288,18 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
const totalValue = currentPositions.currentValue.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(
|
const symbols = currentPositions.positions.map(
|
||||||
(position) => position.symbol
|
(position) => position.symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.get(symbols),
|
this.dataProviderService.get(dataGatheringItems),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -211,6 +314,11 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of currentPositions.positions) {
|
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 value = item.quantity.mul(item.marketPrice);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
@ -218,6 +326,7 @@ export class PortfolioService {
|
|||||||
allocationCurrent: value.div(totalValue).toNumber(),
|
allocationCurrent: value.div(totalValue).toNumber(),
|
||||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
countries: symbolProfile.countries,
|
countries: symbolProfile.countries,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
exchange: dataProviderResponse.exchange,
|
exchange: dataProviderResponse.exchange,
|
||||||
@ -228,6 +337,8 @@ export class PortfolioService {
|
|||||||
marketPrice: item.marketPrice,
|
marketPrice: item.marketPrice,
|
||||||
marketState: dataProviderResponse.marketState,
|
marketState: dataProviderResponse.marketState,
|
||||||
name: symbolProfile.name,
|
name: symbolProfile.name,
|
||||||
|
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
||||||
|
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
||||||
quantity: item.quantity.toNumber(),
|
quantity: item.quantity.toNumber(),
|
||||||
sectors: symbolProfile.sectors,
|
sectors: symbolProfile.sectors,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
@ -243,7 +354,7 @@ export class PortfolioService {
|
|||||||
value: totalValue
|
value: totalValue
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getValueOfAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -257,7 +368,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): 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(
|
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||||
(order) => order.symbol === aSymbol
|
(order) => order.symbol === aSymbol
|
||||||
@ -275,17 +386,26 @@ export class PortfolioService {
|
|||||||
marketPrice: undefined,
|
marketPrice: undefined,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
|
name: undefined,
|
||||||
|
netPerformance: undefined,
|
||||||
|
netPerformancePercent: undefined,
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
value: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetClass = orders[0].SymbolProfile?.assetClass;
|
||||||
|
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
|
||||||
const positionCurrency = orders[0].currency;
|
const positionCurrency = orders[0].currency;
|
||||||
|
const name = orders[0].SymbolProfile?.name ?? '';
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
|
fee: new Big(order.fee),
|
||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
@ -313,13 +433,14 @@ export class PortfolioService {
|
|||||||
const {
|
const {
|
||||||
averagePrice,
|
averagePrice,
|
||||||
currency,
|
currency,
|
||||||
|
dataSource,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
quantity,
|
quantity,
|
||||||
transactionCount
|
transactionCount
|
||||||
} = position;
|
} = 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 userCurrency = this.request.user.Settings.currency;
|
||||||
const investment = this.exchangeRateDataService.toCurrency(
|
const investment = this.exchangeRateDataService.toCurrency(
|
||||||
position.investment.toNumber(),
|
position.investment.toNumber(),
|
||||||
@ -331,9 +452,14 @@ export class PortfolioService {
|
|||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
|
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
|
position.netPerformance.toNumber(),
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
|
||||||
const historicalData = await this.dataProviderService.getHistorical(
|
const historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[{ dataSource, symbol: aSymbol }],
|
||||||
'day',
|
'day',
|
||||||
parseISO(firstBuyDate),
|
parseISO(firstBuyDate),
|
||||||
new Date()
|
new Date()
|
||||||
@ -385,6 +511,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
currency,
|
currency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
@ -392,19 +520,29 @@ export class PortfolioService {
|
|||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
|
name,
|
||||||
|
netPerformance,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
|
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
symbol: aSymbol
|
symbol: aSymbol,
|
||||||
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
|
quantity.mul(marketPrice).toNumber(),
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
const currentData = await this.dataProviderService.get([
|
||||||
|
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||||
|
]);
|
||||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||||
|
|
||||||
let historicalData = await this.dataProviderService.getHistorical(
|
let historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||||
'day',
|
'day',
|
||||||
portfolioStart,
|
portfolioStart,
|
||||||
new Date()
|
new Date()
|
||||||
@ -435,9 +573,12 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
|
name,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
currency: currentData[aSymbol]?.currency,
|
currency: currentData[aSymbol]?.currency,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -445,9 +586,12 @@ export class PortfolioService {
|
|||||||
grossPerformancePercent: undefined,
|
grossPerformancePercent: undefined,
|
||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
investment: 0,
|
investment: 0,
|
||||||
|
netPerformance: undefined,
|
||||||
|
netPerformancePercent: undefined,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
value: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -456,7 +600,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
): 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(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -483,10 +627,16 @@ export class PortfolioService {
|
|||||||
const positions = currentPositions.positions.filter(
|
const positions = currentPositions.positions.filter(
|
||||||
(item) => !item.quantity.eq(0)
|
(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 symbols = positions.map((position) => position.symbol);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.get(symbols),
|
this.dataProviderService.get(dataGatheringItem),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -508,6 +658,9 @@ export class PortfolioService {
|
|||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState: dataProviderResponses[position.symbol].marketState,
|
marketState: dataProviderResponses[position.symbol].marketState,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
|
netPerformancePercentage:
|
||||||
|
position.netPerformancePercentage?.toNumber() ?? null,
|
||||||
quantity: new Big(position.quantity).toNumber()
|
quantity: new Big(position.quantity).toNumber()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -518,7 +671,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): 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(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -531,9 +684,14 @@ export class PortfolioService {
|
|||||||
return {
|
return {
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
performance: {
|
performance: {
|
||||||
|
annualizedPerformancePercent: 0,
|
||||||
currentGrossPerformance: 0,
|
currentGrossPerformance: 0,
|
||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentValue: 0
|
currentNetPerformance: 0,
|
||||||
|
currentNetPerformancePercent: 0,
|
||||||
|
currentValue: 0,
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -547,17 +705,28 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasErrors = currentPositions.hasErrors;
|
const hasErrors = currentPositions.hasErrors;
|
||||||
|
const annualizedPerformancePercent =
|
||||||
|
currentPositions.netAnnualizedPerformance.toNumber();
|
||||||
const currentValue = currentPositions.currentValue.toNumber();
|
const currentValue = currentPositions.currentValue.toNumber();
|
||||||
const currentGrossPerformance =
|
const currentGrossPerformance =
|
||||||
currentPositions.grossPerformance.toNumber();
|
currentPositions.grossPerformance.toNumber();
|
||||||
const currentGrossPerformancePercent =
|
const currentGrossPerformancePercent =
|
||||||
currentPositions.grossPerformancePercentage.toNumber();
|
currentPositions.grossPerformancePercentage.toNumber();
|
||||||
|
const currentNetPerformance = currentPositions.netPerformance.toNumber();
|
||||||
|
const currentNetPerformancePercent =
|
||||||
|
currentPositions.netPerformancePercentage.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||||
performance: {
|
performance: {
|
||||||
|
annualizedPerformancePercent,
|
||||||
currentGrossPerformance,
|
currentGrossPerformance,
|
||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent,
|
||||||
currentValue: currentValue
|
currentNetPerformance,
|
||||||
|
currentNetPerformancePercent,
|
||||||
|
currentValue,
|
||||||
|
isAllTimeHigh: true, // TODO
|
||||||
|
isAllTimeLow: false // TODO
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -579,8 +748,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const userId = await this.getUserId(impersonationId);
|
const currency = this.request.user.Settings.currency;
|
||||||
const baseCurrency = this.request.user.Settings.currency;
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||||
userId
|
userId
|
||||||
@ -594,7 +763,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
this.request.user.Settings.currency
|
currency
|
||||||
);
|
);
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
@ -607,10 +776,10 @@ export class PortfolioService {
|
|||||||
for (const position of currentPositions.positions) {
|
for (const position of currentPositions.positions) {
|
||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getValueOfAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
baseCurrency,
|
currency,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -630,7 +799,7 @@ export class PortfolioService {
|
|||||||
accounts
|
accounts
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -651,7 +820,7 @@ export class PortfolioService {
|
|||||||
currentPositions
|
currentPositions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -661,7 +830,7 @@ export class PortfolioService {
|
|||||||
this.getFees(orders)
|
this.getFees(orders)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -669,7 +838,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
@ -718,14 +887,17 @@ export class PortfolioService {
|
|||||||
allocationCurrent: cashValue.div(value).toNumber(),
|
allocationCurrent: cashValue.div(value).toNumber(),
|
||||||
allocationInvestment: cashValue.div(investment).toNumber(),
|
allocationInvestment: cashValue.div(investment).toNumber(),
|
||||||
assetClass: AssetClass.CASH,
|
assetClass: AssetClass.CASH,
|
||||||
|
assetSubClass: AssetClass.CASH,
|
||||||
countries: [],
|
countries: [],
|
||||||
currency: Currency.CHF,
|
currency: 'CHF',
|
||||||
grossPerformance: 0,
|
grossPerformance: 0,
|
||||||
grossPerformancePercent: 0,
|
grossPerformancePercent: 0,
|
||||||
investment: cashValue.toNumber(),
|
investment: cashValue.toNumber(),
|
||||||
marketPrice: 0,
|
marketPrice: 0,
|
||||||
marketState: MarketState.open,
|
marketState: MarketState.open,
|
||||||
name: 'Cash',
|
name: 'Cash',
|
||||||
|
netPerformance: 0,
|
||||||
|
netPerformancePercent: 0,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
symbol: ghostfolioCashSymbol,
|
symbol: ghostfolioCashSymbol,
|
||||||
@ -768,10 +940,18 @@ export class PortfolioService {
|
|||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
|
fee: new Big(
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
),
|
||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
@ -796,10 +976,10 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccounts(
|
private async getValueOfAccounts(
|
||||||
orders: OrderWithAccount[],
|
orders: OrderWithAccount[],
|
||||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||||
userCurrency: Currency,
|
userCurrency: string,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
@ -811,32 +991,20 @@ export class PortfolioService {
|
|||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ordersByAccount.length <= 0) {
|
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
||||||
// Add account without orders
|
|
||||||
const balance = this.exchangeRateDataService.toCurrency(
|
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
accounts[account.name] = {
|
accounts[account.name] = {
|
||||||
current: balance,
|
current: convertedBalance,
|
||||||
original: balance
|
original: convertedBalance
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
let currentValueOfSymbol =
|
||||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
|
||||||
order.currency,
|
let originalValueOfSymbol = order.quantity * order.unitPrice;
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
|
||||||
order.quantity * order.unitPrice,
|
|
||||||
order.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'SELL') {
|
||||||
currentValueOfSymbol *= -1;
|
currentValueOfSymbol *= -1;
|
||||||
@ -860,19 +1028,19 @@ export class PortfolioService {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string) {
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
aImpersonationId,
|
aImpersonationId,
|
||||||
this.request.user.id
|
aUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
return impersonationUserId || this.request.user.id;
|
return impersonationUserId || aUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
orders: OrderWithAccount[],
|
orders: OrderWithAccount[],
|
||||||
currency: Currency,
|
currency: string,
|
||||||
type: TypeOfOrder
|
type: TypeOfOrder
|
||||||
) {
|
) {
|
||||||
return orders
|
return orders
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { Rule } from '@ghostfolio/api/models/rule';
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RulesService {
|
export class RulesService {
|
||||||
@ -9,7 +8,7 @@ export class RulesService {
|
|||||||
|
|
||||||
public async evaluate<T extends RuleSettings>(
|
public async evaluate<T extends RuleSettings>(
|
||||||
aRules: Rule<T>[],
|
aRules: Rule<T>[],
|
||||||
aUserSettings: { baseCurrency: Currency }
|
aUserSettings: { baseCurrency: string }
|
||||||
) {
|
) {
|
||||||
return aRules
|
return aRules
|
||||||
.filter((rule) => {
|
.filter((rule) => {
|
||||||
|
@ -21,9 +21,9 @@ export class RedisCacheService {
|
|||||||
await this.cache.reset();
|
await this.cache.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(key, value, {
|
||||||
ttl: this.configurationService.get('CACHE_TTL')
|
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
@ -46,7 +47,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||||
|
exports: [SubscriptionService]
|
||||||
})
|
})
|
||||||
export class SubscriptionModule {}
|
export class SubscriptionModule {}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { addDays } from 'date-fns';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Subscription } from '@prisma/client';
|
||||||
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -83,7 +85,26 @@ export class SubscriptionService {
|
|||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.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,7 +1,7 @@
|
|||||||
import { Currency, DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface LookupItem {
|
export interface LookupItem {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Currency, DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface SymbolItem {
|
export interface SymbolItem {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -10,7 +10,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -45,9 +47,28 @@ export class SymbolController {
|
|||||||
/**
|
/**
|
||||||
* Must be after /lookup
|
* Must be after /lookup
|
||||||
*/
|
*/
|
||||||
@Get(':symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
public async getSymbolData(
|
||||||
return this.symbolService.get(symbol);
|
@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,7 +1,8 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Currency, DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -13,17 +14,21 @@ export class SymbolService {
|
|||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
|
||||||
const response = await this.dataProviderService.get([aSymbol]);
|
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
|
if (dataGatheringItem.dataSource && marketPrice) {
|
||||||
return {
|
return {
|
||||||
dataSource,
|
currency,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: <Currency>(<unknown>currency)
|
dataSource: dataGatheringItem.dataSource
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
@ -62,7 +67,7 @@ export class SymbolService {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Currency, ViewMode } from '@prisma/client';
|
import { ViewMode } from '@prisma/client';
|
||||||
|
|
||||||
export interface UserSettingsParams {
|
export interface UserSettingsParams {
|
||||||
currency?: Currency;
|
currency?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
viewMode?: ViewMode;
|
viewMode?: ViewMode;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Currency, ViewMode } from '@prisma/client';
|
import { ViewMode } from '@prisma/client';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingsDto {
|
export class UpdateUserSettingsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -11,7 +12,8 @@ import { UserService } from './user.service';
|
|||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [ConfigurationService, PrismaService, UserService],
|
providers: [ConfigurationService, PrismaService, UserService],
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { locale } from '@ghostfolio/common/config';
|
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
import { Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||||
import { isBefore } from 'date-fns';
|
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
@ -15,11 +15,12 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = Currency.USD;
|
public static DEFAULT_CURRENCY = 'USD';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getUser({
|
public async getUser({
|
||||||
@ -98,25 +99,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (userFromDatabase?.Subscription?.length > 0) {
|
user.subscription = this.subscriptionService.getSubscription(
|
||||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
userFromDatabase?.Subscription
|
||||||
(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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.subscription.type === SubscriptionType.Basic) {
|
if (user.subscription.type === SubscriptionType.Basic) {
|
||||||
user.permissions = user.permissions.filter((permission) => {
|
user.permissions = user.permissions.filter((permission) => {
|
||||||
return permission !== permissions.updateViewMode;
|
return permission !== permissions.updateViewMode;
|
||||||
@ -158,9 +144,15 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
|
currency: baseCurrency,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Settings: {
|
||||||
|
create: {
|
||||||
|
currency: baseCurrency
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -18,8 +19,21 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT || 3333;
|
const port = process.env.PORT || 3333;
|
||||||
await app.listen(port, () => {
|
await app.listen(port, () => {
|
||||||
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://localhost:${port}`);
|
||||||
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logLogo() {
|
||||||
|
Logger.log(' ________ __ ____ ___');
|
||||||
|
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
|
||||||
|
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
|
||||||
|
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
|
||||||
|
Logger.log(
|
||||||
|
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
|
||||||
|
);
|
||||||
|
Logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface UserSettings {
|
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 { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
import { IOrder } from '../services/interfaces/interfaces';
|
||||||
@ -6,7 +6,7 @@ import { OrderType } from './order-type';
|
|||||||
|
|
||||||
export class Order {
|
export class Order {
|
||||||
private account: Account;
|
private account: Account;
|
||||||
private currency: Currency;
|
private currency: string;
|
||||||
private fee: number;
|
private fee: number;
|
||||||
private date: string;
|
private date: string;
|
||||||
private id: string;
|
private id: string;
|
||||||
|
@ -3,7 +3,6 @@ import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.in
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { groupBy } from '@ghostfolio/common/helper';
|
import { groupBy } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||||
import { RuleInterface } from './interfaces/rule.interface';
|
import { RuleInterface } from './interfaces/rule.interface';
|
||||||
@ -29,7 +28,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
|||||||
public groupCurrentPositionsByAttribute(
|
public groupCurrentPositionsByAttribute(
|
||||||
positions: TimelinePosition[],
|
positions: TimelinePosition[],
|
||||||
attribute: keyof TimelinePosition,
|
attribute: keyof TimelinePosition,
|
||||||
baseCurrency: Currency
|
baseCurrency: string
|
||||||
) {
|
) {
|
||||||
return Array.from(groupBy(attribute, positions).entries()).map(
|
return Array.from(groupBy(attribute, positions).entries()).map(
|
||||||
([attributeValue, objs]) => ({
|
([attributeValue, objs]) => ({
|
||||||
|
@ -2,8 +2,6 @@ import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/curre
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
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';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
@ -69,5 +67,5 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/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 { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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 { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
@ -69,5 +68,5 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/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 { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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 { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/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 { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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 { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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 { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
@ -46,6 +45,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
interface Settings extends RuleSettings {
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,11 @@ export class CronService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_12_HOURS)
|
@Cron(CronExpression.EVERY_12_HOURS)
|
||||||
public async runEveryTwelveHours() {
|
public async runEveryTwelveHours() {
|
||||||
await this.dataGatheringService.gatherProfileData();
|
|
||||||
await this.exchangeRateDataService.loadCurrencies();
|
await this.exchangeRateDataService.loadCurrencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_WEEKEND)
|
||||||
|
public async runEveryWeekend() {
|
||||||
|
await this.dataGatheringService.gatherProfileData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CryptocurrencyService } from './cryptocurrency.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CryptocurrencyService],
|
||||||
|
exports: [CryptocurrencyService]
|
||||||
|
})
|
||||||
|
export class CryptocurrencyModule {}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
const cryptocurrencies = require('cryptocurrencies');
|
||||||
|
|
||||||
|
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CryptocurrencyService {
|
||||||
|
private combinedCryptocurrencies: string[];
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public isCrypto(aSymbol = '') {
|
||||||
|
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
||||||
|
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCryptocurrencies() {
|
||||||
|
if (!this.combinedCryptocurrencies) {
|
||||||
|
this.combinedCryptocurrencies = [
|
||||||
|
...cryptocurrencies.symbols(),
|
||||||
|
...Object.keys(customCryptocurrencies)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.combinedCryptocurrencies;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"1INCH": "1inch",
|
||||||
|
"ALGO": "Algorand",
|
||||||
|
"AVAX": "Avalanche",
|
||||||
|
"MATIC": "Polygon",
|
||||||
|
"SHIB": "Shiba Inu"
|
||||||
|
}
|
@ -1,12 +1,23 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
|
import { SymbolProfileModule } from './symbol-profile.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataEnhancerModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
providers: [DataGatheringService],
|
providers: [DataGatheringService],
|
||||||
exports: [DataGatheringService]
|
exports: [DataEnhancerModule, DataGatheringService]
|
||||||
})
|
})
|
||||||
export class DataGatheringModule {}
|
export class DataGatheringModule {}
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
DATE_FORMAT,
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
getUtc,
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
isGhostfolioScraperApiSymbol,
|
|
||||||
resetHours
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
endOfToday,
|
|
||||||
format,
|
format,
|
||||||
getDate,
|
getDate,
|
||||||
getMonth,
|
getMonth,
|
||||||
@ -20,25 +15,31 @@ import {
|
|||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
|
private dataGatheringProgress: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
@Inject('DataEnhancers')
|
||||||
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async gather7Days() {
|
||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
if (isDataGatheringNeeded) {
|
||||||
console.log('7d data gathering has been started.');
|
Logger.log('7d data gathering has been started.');
|
||||||
console.time('7d-data-gathering');
|
console.time('data-gathering-7d');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
data: {
|
data: {
|
||||||
@ -61,7 +62,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -70,8 +71,8 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('7d data gathering has been completed.');
|
Logger.log('7d data gathering has been completed.');
|
||||||
console.timeEnd('7d-data-gathering');
|
console.timeEnd('data-gathering-7d');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,8 +82,8 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
console.log('Max data gathering has been started.');
|
Logger.log('Max data gathering has been started.');
|
||||||
console.time('max-data-gathering');
|
console.time('data-gathering-max');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
data: {
|
data: {
|
||||||
@ -105,7 +106,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -114,43 +115,130 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Max data gathering has been completed.');
|
Logger.log('Max data gathering has been completed.');
|
||||||
console.timeEnd('max-data-gathering');
|
console.timeEnd('data-gathering-max');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aSymbols?: string[]) {
|
public async gatherSymbol({
|
||||||
console.log('Profile data gathering has been started.');
|
dataSource,
|
||||||
console.time('profile-data-gathering');
|
symbol
|
||||||
|
}: {
|
||||||
let symbols = aSymbols;
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
if (!symbols) {
|
}) {
|
||||||
const dataGatheringItems = await this.getSymbolsProfileData();
|
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||||
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||||
return dataGatheringItem.symbol;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isDataGatheringLocked) {
|
||||||
|
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
|
||||||
|
console.time('data-gathering-symbol');
|
||||||
|
|
||||||
|
await this.prismaService.property.create({
|
||||||
|
data: {
|
||||||
|
key: 'LOCKED_DATA_GATHERING',
|
||||||
|
value: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbols = (await this.getSymbolsMax()).filter(
|
||||||
|
(dataGatheringItem) => {
|
||||||
|
return (
|
||||||
|
dataGatheringItem.dataSource === dataSource &&
|
||||||
|
dataGatheringItem.symbol === symbol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.gatherSymbols(symbols);
|
||||||
|
|
||||||
|
await this.prismaService.property.upsert({
|
||||||
|
create: {
|
||||||
|
key: 'LAST_DATA_GATHERING',
|
||||||
|
value: new Date().toISOString()
|
||||||
|
},
|
||||||
|
update: { value: new Date().toISOString() },
|
||||||
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentData = await this.dataProviderService.get(symbols);
|
await this.prismaService.property.delete({
|
||||||
|
where: {
|
||||||
|
key: 'LOCKED_DATA_GATHERING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
|
||||||
|
console.timeEnd('data-gathering-symbol');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||||
|
Logger.log('Profile data gathering has been started.');
|
||||||
|
console.time('data-gathering-profile');
|
||||||
|
|
||||||
|
let dataGatheringItems = aDataGatheringItems;
|
||||||
|
|
||||||
|
if (!dataGatheringItems) {
|
||||||
|
dataGatheringItems = await this.getSymbolsProfileData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
dataGatheringItems.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [symbol, response] of Object.entries(currentData)) {
|
||||||
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
|
return symbolProfile.symbol === symbol;
|
||||||
|
})?.symbolMapping;
|
||||||
|
|
||||||
|
for (const dataEnhancer of this.dataEnhancers) {
|
||||||
|
try {
|
||||||
|
currentData[symbol] = await dataEnhancer.enhance({
|
||||||
|
response,
|
||||||
|
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
name,
|
||||||
|
sectors
|
||||||
|
} = currentData[symbol];
|
||||||
|
|
||||||
for (const [
|
|
||||||
symbol,
|
|
||||||
{ assetClass, currency, dataSource, name }
|
|
||||||
] of Object.entries(currentData)) {
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
create: {
|
create: {
|
||||||
assetClass,
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
assetClass,
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
currency,
|
currency,
|
||||||
name
|
name,
|
||||||
|
sectors
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -160,18 +248,21 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${symbol}: ${error?.meta?.cause}`);
|
Logger.error(`${symbol}: ${error?.meta?.cause}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Profile data gathering has been completed.');
|
Logger.log('Profile data gathering has been completed.');
|
||||||
console.timeEnd('profile-data-gathering');
|
console.timeEnd('data-gathering-profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
let symbolCounter = 0;
|
||||||
|
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
|
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource, symbol }],
|
[{ dataSource, symbol }],
|
||||||
@ -207,6 +298,7 @@ export class DataGatheringService {
|
|||||||
try {
|
try {
|
||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: currentDate,
|
date: currentDate,
|
||||||
marketPrice: lastMarketPrice
|
marketPrice: lastMarketPrice
|
||||||
@ -226,28 +318,35 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
||||||
|
Logger.log(
|
||||||
|
`Data gathering progress: ${(
|
||||||
|
this.dataGatheringProgress * 100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
symbolCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.exchangeRateDataService.initialize();
|
||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
throw '';
|
throw '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
public async getDataGatheringProgress() {
|
||||||
startDate?: Date
|
const isInProgress = await this.getIsInProgress();
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const scraperConfigurations =
|
|
||||||
await this.ghostfolioScraperApi.getScraperConfigurations();
|
|
||||||
|
|
||||||
return scraperConfigurations.map((scraperConfiguration) => {
|
if (isInProgress) {
|
||||||
return {
|
return this.dataGatheringProgress;
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
}
|
||||||
date: startDate,
|
|
||||||
symbol: scraperConfiguration.symbol
|
return undefined;
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIsInProgress() {
|
public async getIsInProgress() {
|
||||||
@ -268,8 +367,54 @@ export class DataGatheringService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
|
const startDate =
|
||||||
|
(
|
||||||
|
await this.prismaService.order.findFirst({
|
||||||
|
orderBy: [{ date: 'asc' }]
|
||||||
|
})
|
||||||
|
)?.date ?? new Date();
|
||||||
|
|
||||||
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
Order: {
|
||||||
|
orderBy: [{ date: 'asc' }],
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...this.getBenchmarksToGather(startDate),
|
||||||
|
...currencyPairsToGather,
|
||||||
|
...symbolProfilesToGather
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async reset() {
|
||||||
console.log('Data gathering has been reset.');
|
Logger.log('Data gathering has been reset.');
|
||||||
|
|
||||||
await this.prismaService.property.deleteMany({
|
await this.prismaService.property.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@ -279,19 +424,13 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
const benchmarksToGather: IDataGatheringItem[] = [];
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
benchmarksToGather.push({
|
benchmarksToGather.push({
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: DataSource.RAKUTEN,
|
||||||
date: startDate,
|
date: startDate,
|
||||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -301,81 +440,36 @@ export class DataGatheringService {
|
|||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
const distinctOrders = await this.prismaService.order.findMany({
|
const symbolProfilesToGather = (
|
||||||
distinct: ['symbol'],
|
await this.prismaService.symbolProfile.findMany({
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: { dataSource: true, symbol: true },
|
select: {
|
||||||
where: {
|
dataSource: true,
|
||||||
date: {
|
scraperConfiguration: true,
|
||||||
lt: endOfToday() // no draft
|
symbol: true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
|
||||||
.filter((distinctOrder) => {
|
|
||||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
|
||||||
})
|
})
|
||||||
.map((distinctOrder) => {
|
).map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
...distinctOrder,
|
...symbolProfile,
|
||||||
date: startDate
|
date: startDate
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather = currencyPairs.map(
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
({ dataSource, symbol }) => {
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: startDate
|
date: startDate
|
||||||
};
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...distinctOrdersWithDate
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
|
||||||
const startDate = new Date(getUtc('2015-01-01'));
|
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
const currencyPairsToGather = currencyPairs.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...this.getBenchmarksToGather(startDate),
|
...this.getBenchmarksToGather(startDate),
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
...currencyPairsToGather,
|
||||||
...distinctOrders
|
...symbolProfilesToGather
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,15 +2,15 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -78,12 +78,16 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error, symbol);
|
Logger.error(error, symbol);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.ALPHA_VANTAGE;
|
||||||
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
const result = await this.alphaVantage.data.search(aSymbol);
|
const result = await this.alphaVantage.data.search(aSymbol);
|
||||||
|
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
inject: [TrackinsightDataEnhancerService],
|
||||||
|
provide: 'DataEnhancers',
|
||||||
|
useFactory: (trackinsight) => [trackinsight]
|
||||||
|
},
|
||||||
|
TrackinsightDataEnhancerService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class DataEnhancerModule {}
|
@ -0,0 +1,77 @@
|
|||||||
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
|
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import bent from 'bent';
|
||||||
|
|
||||||
|
const getJSON = bent('json');
|
||||||
|
|
||||||
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static sectorsMapping = {
|
||||||
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
|
'Health Care': 'Healthcare',
|
||||||
|
'Information Technology': 'Technology'
|
||||||
|
};
|
||||||
|
|
||||||
|
public async enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: IDataProviderResponse;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderResponse> {
|
||||||
|
if (
|
||||||
|
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
|
||||||
|
) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdings = await getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||||
|
).catch(() => {
|
||||||
|
return getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/${
|
||||||
|
symbol.split('.')[0]
|
||||||
|
}.json`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.countries || response.countries.length === 0) {
|
||||||
|
response.countries = [];
|
||||||
|
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
||||||
|
let countryCode: string;
|
||||||
|
|
||||||
|
for (const [key, country] of Object.entries<any>(
|
||||||
|
TrackinsightDataEnhancerService.countries
|
||||||
|
)) {
|
||||||
|
if (country.name === name) {
|
||||||
|
countryCode = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.countries.push({
|
||||||
|
code: countryCode,
|
||||||
|
weight: value.weight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.sectors || response.sectors.length === 0) {
|
||||||
|
response.sectors = [];
|
||||||
|
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
||||||
|
response.sectors.push({
|
||||||
|
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||||
|
weight: value.weight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return 'TRACKINSIGHT';
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,49 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigurationModule, PrismaModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
CryptocurrencyModule,
|
||||||
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
|
YahooFinanceService,
|
||||||
|
{
|
||||||
|
inject: [
|
||||||
|
AlphaVantageService,
|
||||||
|
GhostfolioScraperApiService,
|
||||||
|
RakutenRapidApiService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
],
|
],
|
||||||
|
provide: 'DataProviderInterfaces',
|
||||||
|
useFactory: (
|
||||||
|
alphaVantageService,
|
||||||
|
ghostfolioScraperApiService,
|
||||||
|
rakutenRapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
) => [
|
||||||
|
alphaVantageService,
|
||||||
|
ghostfolioScraperApiService,
|
||||||
|
rakutenRapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -1,90 +1,57 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import {
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
DATE_FORMAT,
|
|
||||||
isGhostfolioScraperApiSymbol,
|
|
||||||
isRakutenRapidApiSymbol
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
|
||||||
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly alphaVantageService: AlphaVantageService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
@Inject('DataProviderInterfaces')
|
||||||
private readonly prismaService: PrismaService,
|
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
private readonly prismaService: PrismaService
|
||||||
private readonly yahooFinanceService: YahooFinanceService
|
) {}
|
||||||
) {
|
|
||||||
this.rakutenRapidApiService?.setPrisma(this.prismaService);
|
public async get(items: IDataGatheringItem[]): Promise<{
|
||||||
|
[symbol: string]: IDataProviderResponse;
|
||||||
|
}> {
|
||||||
|
const response: {
|
||||||
|
[symbol: string]: IDataProviderResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const dataProvider = this.getDataProvider(item.dataSource);
|
||||||
|
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||||
|
item.symbol
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
const promises = [];
|
||||||
aSymbols: string[]
|
for (const symbol of Object.keys(response)) {
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
const promise = Promise.resolve(response[symbol]);
|
||||||
if (aSymbols.length === 1) {
|
promises.push(
|
||||||
const symbol = aSymbols[0];
|
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||||
|
|
||||||
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) => {
|
await Promise.all(promises);
|
||||||
return isRakutenRapidApiSymbol(symbol);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const symbol of rakutenRapidApiSymbols) {
|
|
||||||
if (symbol) {
|
|
||||||
const rakutenRapidApiResult =
|
|
||||||
await this.ghostfolioScraperApiService.get([symbol]);
|
|
||||||
response[symbol] = rakutenRapidApiResult[symbol];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aItems: IDataGatheringItem[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
@ -95,6 +62,10 @@ export class DataProviderService {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
if (isEmpty(aItems)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const granularityQuery =
|
const granularityQuery =
|
||||||
aGranularity === 'month'
|
aGranularity === 'month'
|
||||||
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
|
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
|
||||||
@ -108,10 +79,21 @@ export class DataProviderService {
|
|||||||
)}'`
|
)}'`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const dataSources = aItems.map((item) => {
|
||||||
|
return item.dataSource;
|
||||||
|
});
|
||||||
|
const symbols = aItems.map((item) => {
|
||||||
|
return item.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queryRaw = `SELECT * FROM "MarketData" WHERE "symbol" IN ('${aSymbols.join(
|
const queryRaw = `SELECT *
|
||||||
|
FROM "MarketData"
|
||||||
|
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||||
|
AND "symbol" IN ('${symbols.join(
|
||||||
`','`
|
`','`
|
||||||
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
|
)}') ${granularityQuery} ${rangeQuery}
|
||||||
|
ORDER BY date;`;
|
||||||
|
|
||||||
const marketDataByGranularity: MarketData[] =
|
const marketDataByGranularity: MarketData[] =
|
||||||
await this.prismaService.$queryRaw(queryRaw);
|
await this.prismaService.$queryRaw(queryRaw);
|
||||||
@ -127,7 +109,7 @@ export class DataProviderService {
|
|||||||
return r;
|
return r;
|
||||||
}, {});
|
}, {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -168,13 +150,24 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
const { items } = await this.getDataProvider(
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
let lookupItems: LookupItem[] = [];
|
||||||
).search(aSymbol);
|
|
||||||
|
|
||||||
const filteredItems = items.filter((item) => {
|
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||||
|
promises.push(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = await Promise.all(promises);
|
||||||
|
|
||||||
|
searchResults.forEach((searchResult) => {
|
||||||
|
lookupItems = lookupItems.concat(searchResult.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredItems = lookupItems.filter((lookupItem) => {
|
||||||
// Only allow symbols with supported currency
|
// Only allow symbols with supported currency
|
||||||
return item.currency ? true : false;
|
return lookupItem.currency ? true : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -182,18 +175,16 @@ export class DataProviderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPrimaryDataSource(): DataSource {
|
||||||
|
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||||
|
}
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
switch (providerName) {
|
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
||||||
case DataSource.ALPHA_VANTAGE:
|
if (dataProviderInterface.getName() === providerName) {
|
||||||
return this.alphaVantageService;
|
return dataProviderInterface;
|
||||||
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.');
|
throw new Error('No data provider has been found.');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,33 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
isGhostfolioScraperApiSymbol
|
isGhostfolioScraperApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||||
|
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return isGhostfolioScraperApiSymbol(symbol);
|
return isGhostfolioScraperApiSymbol(symbol);
|
||||||
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
|
[symbol]
|
||||||
|
);
|
||||||
|
|
||||||
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: scraperConfig?.currency,
|
currency: symbolProfile?.currency,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
marketState: MarketState.delayed
|
marketState: MarketState.delayed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
|
||||||
startDate?: Date
|
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
where: {
|
|
||||||
dataSource: DataSource.GHOSTFOLIO
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbols: string[],
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
|
[symbol]
|
||||||
symbol
|
|
||||||
);
|
);
|
||||||
|
const scraperConfiguration = symbolProfile?.scraperConfiguration;
|
||||||
|
|
||||||
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
||||||
|
|
||||||
@ -122,26 +106,14 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
public getName(): DataSource {
|
||||||
try {
|
return DataSource.GHOSTFOLIO;
|
||||||
const { value: scraperConfigString } =
|
|
||||||
await this.prismaService.property.findFirst({
|
|
||||||
select: {
|
|
||||||
value: true
|
|
||||||
},
|
|
||||||
where: { key: 'SCRAPER_CONFIG' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.parse(scraperConfigString);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
@ -158,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getScraperConfigurationBySymbol(aSymbol: string) {
|
|
||||||
const scraperConfigurations = await this.getScraperConfigurations();
|
|
||||||
return scraperConfigurations.find((scraperConfiguration) => {
|
|
||||||
return scraperConfiguration.symbol === aSymbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface ScraperConfig {
|
|
||||||
currency: Currency;
|
|
||||||
selector: string;
|
|
||||||
symbol: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface ScraperConfiguration {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
|
export interface DataEnhancerInterface {
|
||||||
|
enhance({
|
||||||
|
response,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
response: IDataProviderResponse;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderResponse>;
|
||||||
|
|
||||||
|
getName(): string;
|
||||||
|
}
|
@ -1,10 +1,11 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from './interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
||||||
@ -20,5 +21,7 @@ export interface DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
getName(): DataSource;
|
||||||
|
|
||||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getToday,
|
getToday,
|
||||||
@ -8,26 +9,25 @@ import {
|
|||||||
isRakutenRapidApiSymbol
|
isRakutenRapidApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RakutenRapidApiService implements DataProviderInterface {
|
export class RakutenRapidApiService implements DataProviderInterface {
|
||||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||||
|
|
||||||
private prismaService: PrismaService;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
@ -47,11 +47,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const symbol = aSymbols[0];
|
||||||
|
|
||||||
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'GF.FEAR_AND_GREED_INDEX': {
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
currency: undefined,
|
currency: undefined,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: DataSource.RAKUTEN,
|
||||||
marketPrice: fgi.now.value,
|
marketPrice: fgi.now.value,
|
||||||
@ -61,7 +61,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -82,7 +82,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const symbol = aSymbols[0];
|
||||||
|
|
||||||
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -93,6 +93,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
date: subWeeks(getToday(), 1),
|
date: subWeeks(getToday(), 1),
|
||||||
marketPrice: fgi.oneWeekAgo.value
|
marketPrice: fgi.oneWeekAgo.value
|
||||||
}
|
}
|
||||||
@ -101,6 +102,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
date: subMonths(getToday(), 1),
|
date: subMonths(getToday(), 1),
|
||||||
marketPrice: fgi.oneMonthAgo.value
|
marketPrice: fgi.oneMonthAgo.value
|
||||||
}
|
}
|
||||||
@ -109,6 +111,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
date: subYears(getToday(), 1),
|
date: subYears(getToday(), 1),
|
||||||
marketPrice: fgi.oneYearAgo.value
|
marketPrice: fgi.oneYearAgo.value
|
||||||
}
|
}
|
||||||
@ -118,7 +121,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'GF.FEAR_AND_GREED_INDEX': {
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
[format(getYesterday(), DATE_FORMAT)]: {
|
[format(getYesterday(), DATE_FORMAT)]: {
|
||||||
marketPrice: fgi.previousClose.value
|
marketPrice: fgi.previousClose.value
|
||||||
}
|
}
|
||||||
@ -130,12 +133,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public getName(): DataSource {
|
||||||
return { items: [] };
|
return DataSource.RAKUTEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPrisma(aPrismaService: PrismaService) {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
this.prismaService = aPrismaService;
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFearAndGreedIndex(): Promise<{
|
private async getFearAndGreedIndex(): Promise<{
|
||||||
@ -163,7 +166,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
const { fgi } = await get();
|
const { fgi } = await get();
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user