Compare commits
294 Commits
Author | SHA1 | Date | |
---|---|---|---|
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde | |||
0d676a46c8 | |||
97db144e01 | |||
cec55127c8 | |||
f3f359bcfb | |||
601e6f4147 | |||
e228b4925c | |||
62e3ffe413 | |||
6af885fde0 | |||
dd15bba359 | |||
43fca7ff43 | |||
faa6af5694 | |||
d2ea7a0bfb | |||
3f6319e00b | |||
5601299648 | |||
6060c7cfe0 | |||
ba78c2783d | |||
48eee5f865 | |||
f4a8acdb46 | |||
1d6ba22598 | |||
e38be8d710 | |||
da5be3fb57 | |||
b5317a7f95 | |||
43afb16808 | |||
d5c56fb16c | |||
b94c1f280b | |||
acc59866a3 | |||
c9fc3e402d | |||
6c1317f978 | |||
89be438e66 | |||
9d6214e93a | |||
0640b24290 | |||
6eb9d9d973 | |||
9ecc3176a5 | |||
96434c5a54 | |||
4063c62a17 | |||
890c5b986c | |||
423bd92b89 | |||
5dc331e386 | |||
744dc51dcd | |||
b0c53d050a | |||
830569b38e | |||
35b4aef06f | |||
bc2fd9c970 | |||
c42a8aebed | |||
fad1adb91b | |||
9cd37f8de0 | |||
d49b90d7a5 | |||
130a9ea062 | |||
ffc6309850 | |||
976cc7f243 | |||
7067aca04b | |||
1c9805bb96 | |||
8227a2d91a | |||
194aee97db | |||
0f77169952 | |||
0f8dc62c53 | |||
554136cdcd | |||
83b5cfff1f | |||
dcec3accf0 | |||
f08b0b570b | |||
8386fec98a | |||
4d3dff3e5b | |||
76890e63fa | |||
4fb2aebf4f | |||
ed5cd3b978 | |||
469c1936b4 | |||
8b3cc5c11a | |||
ee086638f3 | |||
58d1abbd38 | |||
ba979cbae2 | |||
8cda43bb63 | |||
c4499df74c | |||
24bcc15b6a | |||
ff121243e4 | |||
70e633b997 | |||
0780ee4adb | |||
09613f9324 | |||
8642b1a7af | |||
f96f861341 | |||
a201fc7a97 | |||
a97110348c | |||
a25d5b9dc0 | |||
6c2acf2aa6 | |||
519827045a | |||
79a7e12a9f | |||
bf20a5de82 | |||
0adefe14e1 | |||
f24561cc3d | |||
873fd53715 | |||
e5d8faf2dc | |||
65d3bd2802 | |||
ad60373813 | |||
b725e6e2ec | |||
88c420ca5e | |||
118e17f78c | |||
cc92592d86 | |||
46eb3254a9 | |||
2477491f18 | |||
5fc9fde129 | |||
00e50c6abe | |||
8131a7ad03 | |||
f5e6f7dcfe | |||
87501e094d | |||
d3bfdf78c3 | |||
fc4e6ae6db | |||
23e4d5454d | |||
fdcf5fd396 | |||
8a9ae9bb33 | |||
3fb7e746df | |||
137e8e090a | |||
6d941500cd | |||
9c4e22978d | |||
af65a99398 | |||
1a0cb561cd | |||
9f875adf0c | |||
6b0dadb895 | |||
98a9523eee | |||
dfb3365efb | |||
0e08d8830e | |||
69d85eadfd | |||
c009f8c12f | |||
60ef46accf | |||
b12ac1fe84 | |||
4355c96ab6 | |||
fb326fe0cc | |||
02cfebd98c | |||
dd2936d703 | |||
918d0b85d4 | |||
a061595101 | |||
dcd496ac50 | |||
6b9ec549da | |||
b5bd4df483 | |||
766a2d7c2f | |||
6dabf7516a | |||
5d49ff7a4a | |||
8998c18836 | |||
741a0e36d2 | |||
e31b4c64cb | |||
812ff5cbdc | |||
035b90a689 | |||
5ea3a187f4 | |||
5d9c38663d | |||
5616bc4956 | |||
9ad1c2177c | |||
15bf9f2f9c | |||
ebc5008569 | |||
37759ba03f | |||
8319b216bb | |||
782d131b0d | |||
72e75208df | |||
4b1c27c245 | |||
61f0da35bc | |||
80464c7846 | |||
74f4323903 | |||
127dbf9dcd | |||
66bdb374e8 | |||
4ad4fa2b30 | |||
1fd836194f | |||
2090db1199 | |||
053c7e591e | |||
9b5e350e3b | |||
378e57c3bc | |||
6765191a8c | |||
8438a45bcf | |||
30a64e7fc1 | |||
f2cb671c7f | |||
3f41e5c5de | |||
c1ad483f33 | |||
f3d961bc16 | |||
42b70ef568 | |||
77beaaba08 | |||
d9c07456cd | |||
0a53df4293 | |||
4416ba0c88 | |||
486de968a2 | |||
a5833566a8 | |||
261f5844dd | |||
2173c418a7 | |||
4efd5cefd8 | |||
d735e4db75 | |||
e10707fde4 | |||
ac953df809 | |||
bb4ee50738 | |||
4f41bac328 | |||
cd07802400 | |||
b692b7432c | |||
a4efbc0131 | |||
55b0fe232c | |||
46432edce9 | |||
990028316e | |||
37871fbabc | |||
0fdeef7953 | |||
cdbe6eedeb | |||
a6dde8ad43 | |||
39bd4a349b | |||
ab59eb5c92 | |||
1132dc9bdd | |||
2d70b18593 | |||
b6ea7d23fa | |||
95382581f1 | |||
551b83a6e3 | |||
895c4fe299 | |||
ccb1bf881e | |||
7788b5a987 | |||
47fd029e0c | |||
458ee159e1 | |||
dfacbed66d | |||
22d63c6102 | |||
212aa6a63b | |||
bed9ae916c | |||
b6ad362850 | |||
ab86dd5318 | |||
dba73d80a3 | |||
92fb05320a | |||
73d62bb51f | |||
127b7d4f25 | |||
e79d607ab8 | |||
5f7d083f7c | |||
15857118fe | |||
ff91ed21df | |||
9241c04d5a | |||
5d4e2fba8c | |||
6c57609db8 | |||
b31bbbe2d1 | |||
7d308917dd | |||
4e7d93db13 | |||
45340b581f | |||
6f8fe45fc2 | |||
34d9ceb009 | |||
2b97bbd05d | |||
3bf7ac76a0 | |||
71892e67b2 | |||
6e2885ed20 | |||
07c0e5a612 | |||
719bbe156e | |||
b51255a543 | |||
50dbbf0569 | |||
ca2e748c56 | |||
5c480109d5 | |||
6152ff4b44 | |||
c6641fde36 | |||
4ae7e9fcbe | |||
c10ae431a2 | |||
883e30e451 | |||
f1f4f6247d | |||
82fe1de1a7 | |||
371c999fbc | |||
26b9660e11 | |||
ca7717f9c5 | |||
6f3cce1c5f | |||
efdc9b387f | |||
d7b579e3e8 | |||
b8533050b0 | |||
1b81409b35 | |||
8cd6c34ed8 | |||
0c68474802 | |||
34997f91db | |||
084467ee9a | |||
af47889d65 | |||
51203ec96e | |||
a2277dea2c | |||
debd233c32 | |||
f1eeee0525 | |||
5ffc39c32f | |||
a668a66e84 | |||
0581b8b9ec | |||
63a61fb492 | |||
5788c6474e | |||
5529fdc0ee | |||
88a9b518f6 | |||
98de2355c4 | |||
b41eb60348 | |||
0edebe30e1 | |||
e3abe4feee | |||
50391e199a | |||
a33f8d5bed | |||
636be8441e | |||
654dc2ba32 | |||
458ef169f4 | |||
5bb01bb03c | |||
43e9528d8c | |||
522c54c9b4 |
@ -24,12 +24,18 @@
|
|||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {
|
||||||
|
"@typescript-eslint/no-extra-semi": "error",
|
||||||
|
"no-extra-semi": "off"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {
|
||||||
|
"@typescript-eslint/no-extra-semi": "error",
|
||||||
|
"no-extra-semi": "off"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts"],
|
"files": ["*.ts"],
|
||||||
|
12
.github/workflows/build-code.yml
vendored
12
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 18
|
- 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@ -24,16 +24,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'yarn'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --frozen-lockfile
|
run: npm ci
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: yarn format:check
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Execute tests
|
- name: Execute tests
|
||||||
run: yarn test
|
run: npm test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:production
|
run: npm run build:production
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,8 +5,8 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/.yarn
|
|
||||||
/node_modules
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
@ -28,15 +28,14 @@
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
.nx/cache
|
.nx/cache
|
||||||
|
.nx/workspace-data
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
|
/.nx/workspace-data
|
||||||
/apps/client/src/polyfills.ts
|
/apps/client/src/polyfills.ts
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
528
CHANGELOG.md
528
CHANGELOG.md
@ -5,12 +5,534 @@ 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).
|
||||||
|
|
||||||
## 2.66.1 - 2024-03-23
|
## 2.101.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||||
|
|
||||||
|
## 2.100.0 - 2024-08-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to manage tags of holdings in the holding detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||||
|
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||||
|
- Improved the language localization for Catalan (`ca`)
|
||||||
|
- Improved the language localization for Spanish (`es`)
|
||||||
|
|
||||||
|
## 2.99.0 - 2024-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the usage of `yarn` to `npm`
|
||||||
|
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
|
||||||
|
- Downgraded `marked` from version `13.0.0` to `12.0.2`
|
||||||
|
|
||||||
|
## 2.98.0 - 2024-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up the language localization for Catalan (`ca`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the account selector of the create or update activity dialog
|
||||||
|
- Improved the handling of the numerical precision in the value component
|
||||||
|
- Skipped derived currencies in the get quotes functionality of the data provider service
|
||||||
|
- Improved the language localization for Spanish (`es`)
|
||||||
|
- Upgraded `angular` from version `18.0.4` to `18.1.1`
|
||||||
|
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
|
||||||
|
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the dividend import from a data provider for holdings without an account
|
||||||
|
- Fixed an issue in the public page related to a non-existent access
|
||||||
|
|
||||||
|
## 2.97.0 - 2024-07-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _selfh.st_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the numerical precision in the holding detail dialog
|
||||||
|
- Improved the handling of the numerical precision in the value component
|
||||||
|
- Optimized the 7d data gathering by prioritizing the currencies
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
|
||||||
|
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
|
||||||
|
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the table sorting of the holdings tab on the home page
|
||||||
|
|
||||||
|
## 2.96.0 - 2024-07-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the chart of the holdings tab on the home page (experimental)
|
||||||
|
- Separated the icon purposes in the `site.webmanifest`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the portfolio summary with the currency conversion of fees
|
||||||
|
- Fixed an issue in the the search for a holding
|
||||||
|
- Removed the show condition of the experimental features setting in the user settings
|
||||||
|
|
||||||
|
## 2.95.0 - 2024-07-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a chart to the holdings tab of the home page (experimental)
|
||||||
|
|
||||||
|
## 2.94.0 - 2024-07-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
|
||||||
|
|
||||||
|
## 2.93.0 - 2024-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Crypto Coins Heatmap_ to the resources section
|
||||||
|
- Added the _Stock Heatmap_ to the resources section
|
||||||
|
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
|
||||||
|
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
|
||||||
|
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Refactored the thresholds of the rules in the _X-ray_ section
|
||||||
|
- Removed the obsolete `version` from the `docker-compose` files
|
||||||
|
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
|
||||||
|
|
||||||
|
## 2.92.0 - 2024-06-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added support for derived currencies in the currency validation
|
||||||
|
- Added support for automatic deletion of unused asset profiles when deleting activities
|
||||||
|
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
|
||||||
|
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the all time high in the benchmarks of the markets overview
|
||||||
|
|
||||||
|
## 2.91.0 - 2024-06-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a benchmarks preset to the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `18.0.2` to `18.0.4`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the dialog position (center) on mobile
|
||||||
|
- Fixed the horizontal overflow in the historical market data table of the admin control panel
|
||||||
|
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
|
||||||
|
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
|
||||||
|
|
||||||
|
## 2.90.0 - 2024-06-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a dialog for the benchmarks in the markets overview
|
||||||
|
- Extended the asset profile details dialog of the admin control for currencies
|
||||||
|
- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the indicator for active filters from experimental to general availability
|
||||||
|
- Improved the error handling in the biometric authentication registration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Set up SSL for local development
|
||||||
|
- Upgraded the _Stripe_ dependencies
|
||||||
|
- Upgraded `marked` from version `9.1.6` to `13.0.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `5.0.1` to `8.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0`
|
||||||
|
- Upgraded `zone.js` from version `0.14.5` to `0.14.7`
|
||||||
|
|
||||||
|
## 2.89.0 - 2024-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the historical market data table with currencies preset by date and activities count in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the date validation in the create, import and update activities endpoints
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 2.88.0 - 2024-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set the image source label in `Dockerfile`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the blog post list
|
||||||
|
- Migrated the `@ghostfolio/client` components to control flow
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `17.3.10` to `18.0.2`
|
||||||
|
- Upgraded `Nx` from version `19.0.5` to `19.2.2`
|
||||||
|
|
||||||
|
## 2.87.0 - 2024-06-08
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio summary
|
||||||
|
- Improved the allocations by ETF holding on the allocations page (experimental)
|
||||||
|
- Improved the error handling in the `HttpResponseInterceptor`
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.14.0` to `5.15.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the _FIRE_ calculator
|
||||||
|
|
||||||
|
## 2.86.0 - 2024-06-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by ETF holding on the allocations page (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `prettier` from version `3.2.5` to `3.3.1`
|
||||||
|
|
||||||
|
## 2.85.0 - 2024-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the ability to close a user account
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the default locale in the value component
|
||||||
|
|
||||||
|
## 2.84.0 - 2024-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the data provider information to the asset profile details dialog of the admin control
|
||||||
|
- Added the cascading on delete for various relations in the database schema
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator
|
||||||
|
- Fixed the state handling in the currency selector
|
||||||
|
- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
## 2.83.0 - 2024-05-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3`
|
||||||
|
- Upgraded `angular` from version `17.3.5` to `17.3.10`
|
||||||
|
- Upgraded `class-validator` from version `0.14.0` to `0.14.1`
|
||||||
|
- Upgraded `countup.js` from version `2.3.2` to `2.8.0`
|
||||||
|
- Upgraded `Nx` from version `19.0.2` to `19.0.5`
|
||||||
|
- Upgraded `passport` from version `0.6.0` to `0.7.0`
|
||||||
|
- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1`
|
||||||
|
- Upgraded `prisma` from version `5.13.0` to `5.14.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3`
|
||||||
|
|
||||||
|
## 2.82.0 - 2024-05-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the create or update activity dialog by preselecting the (only) account
|
||||||
|
- Improved the usability of the date range selector in the assistant
|
||||||
|
- Refactored the holding detail dialog to a standalone component
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Refactored various pages to standalone components
|
||||||
|
- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2`
|
||||||
|
- Upgraded `body-parser` from version `1.20.1` to `1.20.2`
|
||||||
|
- Upgraded `zone.js` from version `0.14.4` to `0.14.5`
|
||||||
|
|
||||||
|
## 2.81.0 - 2024-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an indicator for active filters (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Türkçe (`tr`)
|
||||||
|
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the position detail dialog close functionality
|
||||||
|
|
||||||
|
## 2.80.0 - 2024-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the absolute change column to the holdings table on the home page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the spacing around the floating action buttons (FAB)
|
||||||
|
- Set the icon column of the activities table to stick at the beginning
|
||||||
|
- Set the icon column of the holdings table to stick at the beginning
|
||||||
|
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
|
||||||
|
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
|
||||||
|
|
||||||
|
## 2.79.0 - 2024-05-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the holdings table to the holdings tab of the home page
|
||||||
|
- Improved the performance labels (with and without currency effects) in the position detail dialog
|
||||||
|
- Optimized the calculations of the portfolio details endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the benchmarks in the markets overview
|
||||||
|
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
|
||||||
|
|
||||||
|
## 2.78.0 - 2024-05-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a form validation against the DTO in the create or update access dialog
|
||||||
|
- Added a form validation against the DTO in the asset profile details dialog of the admin control
|
||||||
|
- Added a form validation against the DTO in the platform management of the admin control panel
|
||||||
|
- Added a form validation against the DTO in the tag management of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set the performance column of the holdings table to stick at the end
|
||||||
|
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
|
||||||
|
- Improved the `INACTIVE` user role
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||||
|
- Fixed a division by zero error in the dividend yield calculation (experimental)
|
||||||
|
|
||||||
|
## 2.77.1 - 2024-04-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
|
||||||
|
- Added the caching to the portfolio calculator (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the `@ghostfolio/ui` components to control flow
|
||||||
|
- Updated the browserslist database
|
||||||
|
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
|
||||||
|
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
|
||||||
|
|
||||||
|
## 2.76.0 - 2024-04-23
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed `CASH` to `LIQUIDITY` in the asset class enum
|
||||||
|
|
||||||
|
## 2.75.1 - 2024-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the chart in the account detail dialog
|
||||||
|
- Improved the account balance management
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
|
||||||
|
|
||||||
|
## 2.74.0 - 2024-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range support to the portfolio holdings page
|
||||||
|
- Added support to create an account balance
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the date range support in the activities table on the portfolio activities page (experimental)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `17.3.3` to `17.3.5`
|
||||||
|
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
|
||||||
|
|
||||||
|
## 2.73.0 - 2024-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a form validation against the DTO in the create or update account dialog
|
||||||
|
- Added a form validation against the DTO in the create or update activity dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the dividend calculations into the portfolio calculator
|
||||||
|
- Moved the fee calculations into the portfolio calculator
|
||||||
|
- Moved the interest calculations into the portfolio calculator
|
||||||
|
- Moved the liability calculations into the portfolio calculator
|
||||||
|
- Moved the (wealth) item calculations into the portfolio calculator
|
||||||
|
- Let queue jobs for asset profile data gathering fail by throwing an error
|
||||||
|
- Let queue jobs for historical market data gathering fail by throwing an error
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2`
|
||||||
|
|
||||||
|
## 2.72.0 - 2024-04-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to immediately execute a queue job from the admin control panel
|
||||||
|
- Added a priority column to the queue jobs view in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Adapted the priorities of queue jobs
|
||||||
|
- Upgraded `angular` from version `17.2.4` to `17.3.3`
|
||||||
|
- Upgraded `Nx` from version `18.1.2` to `18.2.3`
|
||||||
|
- Upgraded `prisma` from version `5.11.0` to `5.12.1`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the public page
|
||||||
|
|
||||||
|
## 2.71.0 - 2024-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the dividend yield to the position detail dialog (experimental)
|
||||||
|
- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control
|
||||||
|
- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control
|
||||||
|
- Added support to override the url of an asset profile in the asset profile details dialog of the admin control
|
||||||
|
- Added the asset profile icon to the asset profile details dialog of the admin control
|
||||||
|
- Added the platform icon to the create or update platform dialog of the admin control
|
||||||
|
- Extended the rules in the _X-ray_ section by a `key`
|
||||||
|
- Added `currency` to the `Order` database schema as a preparation to set a custom currency
|
||||||
|
- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the calculation of allocations by market
|
||||||
|
- Improved the url validation in the create and update platform endpoint
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing tags in the portfolio calculations
|
||||||
|
|
||||||
|
## 2.70.0 - 2024-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up the language localization for Chinese (`zh`)
|
||||||
|
- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes
|
||||||
|
- Set up _Webpack Bundle Analyzer_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Disabled the option to update the cash balance of an account if date is not today
|
||||||
|
- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental)
|
||||||
|
- Introduced a factory for the portfolio calculations to support different algorithms in future
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the duplicated tags in the position detail dialog
|
||||||
|
- Removed `Tini` from the docker image
|
||||||
|
|
||||||
|
## 2.69.0 - 2024-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range support in the activities table on the portfolio activities page (experimental)
|
||||||
|
- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental)
|
||||||
|
- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel
|
||||||
|
|
||||||
|
## 2.68.0 - 2024-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the export functionality by the user account’s currency
|
||||||
|
- Added support to override the name of an asset profile in the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the portfolio calculations
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the chart tooltip of the benchmark comparator
|
||||||
|
- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides
|
||||||
|
|
||||||
|
## 2.67.0 - 2024-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings
|
||||||
|
- Upgraded `ionicons` from version `7.1.0` to `7.3.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0`
|
||||||
|
- Upgraded `zone.js` from version `0.14.3` to `0.14.4`
|
||||||
|
|
||||||
|
## 2.66.3 - 2024-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
|
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
|
||||||
- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image
|
|
||||||
- Added an index for `dataSource` / `symbol` to the market data database table
|
- Added an index for `dataSource` / `symbol` to the market data database table
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -4358,7 +4880,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added the attribute `precision` in the value component
|
- Added the attribute `precision` to the value component
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
@ -30,26 +30,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
|||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `npx nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
1. Run `npx nx migrate --run-migrations`
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Access database via GUI
|
#### Access database via GUI
|
||||||
|
|
||||||
Run `yarn database:gui`
|
Run `npm run database:gui`
|
||||||
|
|
||||||
https://www.prisma.io/studio
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `npm run database:push`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
#### Create schema migration
|
#### Create schema migration
|
||||||
|
|
||||||
Run `yarn prisma migrate dev --name added_job_title`
|
Run `npm run prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||||
|
40
Dockerfile
40
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
|
|||||||
COPY ./CHANGELOG.md CHANGELOG.md
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./yarn.lock yarn.lock
|
COPY ./package-lock.json package-lock.json
|
||||||
COPY ./.yarnrc .yarnrc
|
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
make \
|
make \
|
||||||
openssl \
|
openssl \
|
||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
RUN npm install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
@ -33,37 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
# package.json was generated by the build process, however the original
|
# package.json was generated by the build process, however the original
|
||||||
# yarn.lock needs to be used to ensure the same versions
|
# package-lock.json needs to be used to ensure the same versions
|
||||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
# Overwrite the generated package.json with the original one to ensure having
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
# all the scripts
|
# all the scripts
|
||||||
COPY package.json /ghostfolio/dist/apps/api
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
RUN yarn database:generate-typings
|
RUN npm run database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:18-slim
|
FROM node:20-slim
|
||||||
RUN apt update && apt install -y \
|
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
curl \
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Add tini, which is an init process that handles signaling within the container
|
|
||||||
# and with the host. See https://github.com/krallin/tini
|
|
||||||
ENV TINI_VERSION v0.19.0
|
|
||||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
|
|
||||||
RUN chmod +x /tini
|
|
||||||
ENTRYPOINT ["/tini", "--"]
|
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
COPY ./docker/entrypoint.sh /entrypoint.sh
|
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||||
|
RUN chown -R node:node /ghostfolio
|
||||||
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "entrypoint.sh" ]
|
USER node
|
||||||
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
89
README.md
89
README.md
@ -7,14 +7,12 @@
|
|||||||
**Open Source Wealth Management Software**
|
**Open Source Wealth Management Software**
|
||||||
|
|
||||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/ghostfolio)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
@ -49,7 +47,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: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `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
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Type | Default Value | Description |
|
||||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -144,57 +142,56 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `npm install`
|
||||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `npm run database:setup` to initialize the database schema
|
||||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open http://localhost:4200/en in your browser
|
1. Open https://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
Run `yarn start:server`
|
Run `npm run start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
Run `yarn start:storybook`
|
Run `npm run start:storybook`
|
||||||
|
|
||||||
### Migrate Database
|
### Migrate Database
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync:
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn database:push
|
npm run database:push
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `npm test`
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
@ -206,7 +203,7 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
||||||
|
|
||||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
@ -236,17 +233,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| `accountId` | `string` (optional) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| `comment` | `string` (optional) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| `date` | `string` | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| `fee` | `number` | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| `quantity` | `number` | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| `unitPrice` | `number` | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -277,7 +274,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
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 join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
Body,
|
||||||
|
Post,
|
||||||
Delete,
|
Delete,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccountBalanceService } from './account-balance.service';
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
@Controller('account-balance')
|
@Controller('account-balance')
|
||||||
export class AccountBalanceController {
|
export class AccountBalanceController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
private readonly accountService: AccountService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccountBalance)
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async createAccountBalance(
|
||||||
|
@Body() data: CreateAccountBalanceDto
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const account = await this.accountService.account({
|
||||||
|
id_userId: {
|
||||||
|
id: data.accountId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
|
accountId: account.id,
|
||||||
|
balance: data.balance,
|
||||||
|
date: data.date,
|
||||||
|
userId: account.userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.deleteAccountBalance)
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@ -32,10 +65,11 @@ export class AccountBalanceController {
|
|||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
id
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
if (!accountBalance) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -43,7 +77,8 @@ export class AccountBalanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accountBalanceService.deleteAccountBalance({
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
id
|
id: accountBalance.id,
|
||||||
|
userId: accountBalance.userId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
|
|||||||
controllers: [AccountBalanceController],
|
controllers: [AccountBalanceController],
|
||||||
exports: [AccountBalanceService],
|
exports: [AccountBalanceService],
|
||||||
imports: [ExchangeRateDataModule, PrismaModule],
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
providers: [AccountBalanceService]
|
providers: [AccountBalanceService, AccountService]
|
||||||
})
|
})
|
||||||
export class AccountBalanceModule {}
|
export class AccountBalanceModule {}
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountBalanceService {
|
export class AccountBalanceService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -24,20 +31,63 @@ export class AccountBalanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccountBalance(
|
public async createOrUpdateAccountBalance({
|
||||||
data: Prisma.AccountBalanceCreateInput
|
accountId,
|
||||||
): Promise<AccountBalance> {
|
balance,
|
||||||
return this.prismaService.accountBalance.create({
|
date,
|
||||||
data
|
userId
|
||||||
|
}: CreateAccountBalanceDto & {
|
||||||
|
userId: string;
|
||||||
|
}): Promise<AccountBalance> {
|
||||||
|
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||||
|
create: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
date: resetHours(parseISO(date)),
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
accountId_date: {
|
||||||
|
accountId,
|
||||||
|
date: resetHours(parseISO(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return accountBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
where: Prisma.AccountBalanceWhereUniqueInput
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
return this.prismaService.accountBalance.delete({
|
const accountBalance = await this.prismaService.accountBalance.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: <string>where.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return accountBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccountBalances({
|
public async getAccountBalances({
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccountBalanceDto {
|
||||||
|
@IsUUID()
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
date: string;
|
||||||
|
}
|
@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
@ -19,13 +17,11 @@ import { AccountService } from './account.service';
|
|||||||
imports: [
|
imports: [
|
||||||
AccountBalanceModule,
|
AccountBalanceModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedactValuesInResponseModule
|
||||||
UserModule
|
|
||||||
],
|
],
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -85,17 +90,20 @@ export class AccountService {
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prismaService.accountBalance.create({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
data: {
|
accountId: account.id,
|
||||||
Account: {
|
balance: data.balance,
|
||||||
connect: {
|
date: format(new Date(), DATE_FORMAT),
|
||||||
id_userId: { id: account.id, userId: aUserId }
|
userId: aUserId
|
||||||
}
|
|
||||||
},
|
|
||||||
value: data.balance
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,9 +111,18 @@ export class AccountService {
|
|||||||
where: Prisma.AccountWhereUniqueInput,
|
where: Prisma.AccountWhereUniqueInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prismaService.account.delete({
|
const account = await this.prismaService.account.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
@ -157,8 +174,8 @@ export class AccountService {
|
|||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, ({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
@ -196,21 +213,26 @@ export class AccountService {
|
|||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
await this.prismaService.accountBalance.create({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
data: {
|
accountId: <string>data.id,
|
||||||
Account: {
|
balance: <number>data.balance,
|
||||||
connect: {
|
date: format(new Date(), DATE_FORMAT),
|
||||||
id_userId: where.id_userId
|
userId: aUserId
|
||||||
}
|
|
||||||
},
|
|
||||||
value: <number>data.balance
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.prismaService.account.update({
|
const account = await this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccountBalance({
|
public async updateAccountBalance({
|
||||||
@ -242,17 +264,11 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.accountBalanceService.createAccountBalance({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
date,
|
accountId,
|
||||||
Account: {
|
|
||||||
connect: {
|
|
||||||
id_userId: {
|
|
||||||
userId,
|
userId,
|
||||||
id: accountId
|
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||||
}
|
date: date.toISOString()
|
||||||
}
|
|
||||||
},
|
|
||||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
getAssetProfileIdentifier,
|
|
||||||
resetHours
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -82,10 +81,11 @@ export class AdminController {
|
|||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -94,7 +94,8 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -107,10 +108,11 @@ export class AdminController {
|
|||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -119,7 +121,8 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -141,7 +144,8 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
@ -18,16 +21,19 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
BenchmarkModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
@ -13,21 +15,25 @@ import {
|
|||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
PrismaClient,
|
||||||
Property,
|
Property,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -37,10 +43,12 @@ import { groupBy } from 'lodash';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
@ -146,7 +154,16 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (presetId === 'CURRENCIES') {
|
if (presetId === 'BENCHMARKS') {
|
||||||
|
const benchmarkAssetProfiles =
|
||||||
|
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
|
where.id = {
|
||||||
|
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} else if (presetId === 'CURRENCIES') {
|
||||||
return this.getMarketDataForCurrencies();
|
return this.getMarketDataForCurrencies();
|
||||||
} else if (
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
@ -196,8 +213,11 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
let [assetProfiles, count] = await Promise.all([
|
let [assetProfiles, count] = await Promise.all([
|
||||||
this.prismaService.symbolProfile.findMany({
|
extendedPrismaClient.symbolProfile.findMany({
|
||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
@ -212,6 +232,8 @@ export class AdminService {
|
|||||||
countries: true,
|
countries: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
id: true,
|
||||||
|
isUsedByUsersWithSubscription: true,
|
||||||
name: true,
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -226,8 +248,9 @@ export class AdminService {
|
|||||||
this.prismaService.symbolProfile.count({ where })
|
this.prismaService.symbolProfile.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
let marketData: AdminMarketDataItem[] = await Promise.all(
|
||||||
({
|
assetProfiles.map(
|
||||||
|
async ({
|
||||||
_count,
|
_count,
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
@ -235,12 +258,16 @@ export class AdminService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
isUsedByUsersWithSubscription,
|
||||||
name,
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
}) => {
|
}) => {
|
||||||
const countriesCount = countries ? Object.keys(countries).length : 0;
|
const countriesCount = countries
|
||||||
|
? Object.keys(countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketDataItems.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -257,14 +284,17 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
activitiesCount: _count.Order,
|
activitiesCount: _count.Order,
|
||||||
date: Order?.[0]?.date
|
date: Order?.[0]?.date,
|
||||||
|
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (presetId) {
|
if (presetId) {
|
||||||
@ -285,12 +315,27 @@ export class AdminService {
|
|||||||
count,
|
count,
|
||||||
marketData
|
marketData
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
await extendedPrismaClient.$disconnect();
|
||||||
|
|
||||||
|
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
|
||||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
|
}
|
||||||
|
|
||||||
const [[assetProfile], marketData] = await Promise.all([
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
this.symbolProfileService.getSymbolProfiles([
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -309,11 +354,20 @@ export class AdminService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (assetProfile) {
|
||||||
|
assetProfile.dataProviderInfo = this.dataProviderService
|
||||||
|
.getDataProvider(assetProfile.dataSource)
|
||||||
|
.getDataProviderInfo();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData,
|
marketData,
|
||||||
assetProfile: assetProfile ?? {
|
assetProfile: assetProfile ?? {
|
||||||
symbol,
|
activitiesCount,
|
||||||
currency: '-'
|
currency,
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
|
symbol
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -325,25 +379,45 @@ export class AdminService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
holdings,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
|
url
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
const symbolProfileOverrides = {
|
||||||
assetClass,
|
assetClass: assetClass as AssetClass,
|
||||||
assetSubClass,
|
assetSubClass: assetSubClass as AssetSubClass,
|
||||||
|
name: name as string,
|
||||||
|
url: url as string
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||||
|
{
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
holdings,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
});
|
...(dataSource === 'MANUAL'
|
||||||
|
? { assetClass, assetSubClass, name, url }
|
||||||
|
: {
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
upsert: {
|
||||||
|
create: symbolProfileOverrides,
|
||||||
|
update: symbolProfileOverrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -373,15 +447,72 @@ export class AdminService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getExtendedPrismaClient() {
|
||||||
|
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||||
|
|
||||||
|
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||||
|
return client.$extends({
|
||||||
|
result: {
|
||||||
|
symbolProfile: {
|
||||||
|
isUsedByUsersWithSubscription: {
|
||||||
|
compute: async ({ id }) => {
|
||||||
|
const { _count } =
|
||||||
|
await this.prismaService.symbolProfile.findUnique({
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
Order: {
|
||||||
|
where: {
|
||||||
|
User: {
|
||||||
|
Subscription: {
|
||||||
|
some: {
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return _count.Order > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new PrismaClient().$extends(symbolProfileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
const marketDataPromise: Promise<AdminMarketDataItem>[] =
|
||||||
|
this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(async ({ dataSource, symbol }) => {
|
||||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
|
||||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
|
}
|
||||||
|
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketDataItems.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -391,17 +522,22 @@ export class AdminService {
|
|||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activitiesCount,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: 'CASH',
|
assetClass: AssetClass.LIQUIDITY,
|
||||||
|
assetSubClass: AssetSubClass.CASH,
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
date: dateOfFirstActivity,
|
||||||
|
id: undefined,
|
||||||
name: symbol,
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const marketData = await Promise.all(marketDataPromise);
|
||||||
return { marketData, count: marketData.length };
|
return { marketData, count: marketData.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,4 +46,11 @@ export class QueueController {
|
|||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('job/:id/execute')
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async executeJob(@Param('id') id: string): Promise<void> {
|
||||||
|
return this.queueService.executeJob(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,10 @@ export class QueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async executeJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.promote();
|
||||||
|
}
|
||||||
|
|
||||||
public async getJobs({
|
public async getJobs({
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
status = QUEUE_JOB_STATUS_LIST
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
@ -54,6 +58,7 @@ export class QueueService {
|
|||||||
finishedOn: job.finishedOn,
|
finishedOn: job.finishedOn,
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
|
opts: job.opts,
|
||||||
stacktrace: job.stacktrace,
|
stacktrace: job.stacktrace,
|
||||||
state: await job.getState(),
|
state: await job.getState(),
|
||||||
timestamp: job.timestamp
|
timestamp: job.timestamp
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
IsUrl
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -25,7 +27,7 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
countries?: Prisma.InputJsonArray;
|
countries?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
@ -46,4 +48,11 @@ export class UpdateAssetProfileDto {
|
|||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
[dataProvider: string]: string;
|
[dataProvider: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
@ -23,6 +25,7 @@ import { AccessModule } from './access/access.module';
|
|||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AssetModule } from './asset/asset.module';
|
||||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
@ -44,10 +47,12 @@ import { TagModule } from './tag/tag.module';
|
|||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AppController],
|
||||||
imports: [
|
imports: [
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
|
AssetModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BenchmarkModule,
|
BenchmarkModule,
|
||||||
@ -64,6 +69,8 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
EventsModule,
|
||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
@ -109,7 +116,6 @@ import { UserModule } from './user/user.module';
|
|||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
29
apps/api/src/app/asset/asset.controller.ts
Normal file
29
apps/api/src/app/asset/asset.controller.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
|
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
@Controller('asset')
|
||||||
|
export class AssetController {
|
||||||
|
public constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getAsset(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
const { assetProfile, marketData } =
|
||||||
|
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData,
|
||||||
|
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
17
apps/api/src/app/asset/asset.module.ts
Normal file
17
apps/api/src/app/asset/asset.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AssetController } from './asset.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetController],
|
||||||
|
imports: [
|
||||||
|
AdminModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AssetModule {}
|
@ -1,6 +1,5 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthDeviceService {
|
export class AuthDeviceService {
|
||||||
public constructor(
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async authDevice(
|
public async authDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
|||||||
import { Injectable, Logger } 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 { Profile, Strategy } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
|
|||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
callbackURL: `${configurationService.get(
|
callbackURL: `${configurationService.get(
|
||||||
@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
scope: ['email', 'profile']
|
scope: ['profile']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
request: any,
|
request: any,
|
||||||
token: string,
|
token: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
profile,
|
profile: Profile,
|
||||||
done: Function,
|
done: Function,
|
||||||
done2: Function
|
done2: Function
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const jwt: string = await this.authService.validateOAuthLogin({
|
const jwt = await this.authService.validateOAuthLogin({
|
||||||
provider: Provider.GOOGLE,
|
provider: Provider.GOOGLE,
|
||||||
thirdPartyId: profile.id
|
thirdPartyId: profile.id
|
||||||
});
|
});
|
||||||
const user = {
|
|
||||||
jwt
|
|
||||||
};
|
|
||||||
|
|
||||||
done(null, user);
|
done(null, { jwt });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleStrategy');
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
|
@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
|
import { hasRole } from '@ghostfolio/common/permissions';
|
||||||
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
if (hasRole(user, 'INACTIVE')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const country =
|
const country =
|
||||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
|
|
||||||
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
throw '';
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||||
|
StatusCodes.UNAUTHORIZED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
throw new UnauthorizedException('unauthorized', err.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -103,16 +105,21 @@ export class BenchmarkController {
|
|||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataForUser(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const startDate = new Date(startDateString);
|
const { endDate, startDate } = getInterval(
|
||||||
|
dateRange,
|
||||||
|
new Date(startDateString)
|
||||||
|
);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
return this.benchmarkService.getMarketDataForUser({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate,
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.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/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
@ -17,7 +18,6 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
controllers: [BenchmarkController],
|
controllers: [BenchmarkController],
|
||||||
exports: [BenchmarkService],
|
exports: [BenchmarkService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
@ -25,7 +25,9 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
],
|
],
|
||||||
providers: [BenchmarkService]
|
providers: [BenchmarkService]
|
||||||
})
|
})
|
||||||
|
@ -13,7 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
calculateBenchmarkTrend,
|
calculateBenchmarkTrend,
|
||||||
parseDate
|
parseDate,
|
||||||
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Benchmark,
|
Benchmark,
|
||||||
@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { format, isSameDay, subDays } from 'date-fns';
|
import {
|
||||||
|
differenceInDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
format,
|
||||||
|
isSameDay,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
import { isNumber, last, uniqBy } from 'lodash';
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@ -128,7 +135,7 @@ export class BenchmarkService {
|
|||||||
Promise.all(promisesAllTimeHighs),
|
Promise.all(promisesAllTimeHighs),
|
||||||
Promise.all(promisesBenchmarkTrends)
|
Promise.all(promisesBenchmarkTrends)
|
||||||
]);
|
]);
|
||||||
let storeInCache = true;
|
let storeInCache = useCache;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } =
|
const { marketPrice } =
|
||||||
@ -146,6 +153,7 @@ export class BenchmarkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
dataSource: benchmarkAssetProfiles[index].dataSource,
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
),
|
),
|
||||||
@ -153,9 +161,13 @@ export class BenchmarkService {
|
|||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
date: allTimeHigh?.date,
|
date: allTimeHigh?.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent:
|
||||||
|
performancePercentFromAllTimeHigh >= 0
|
||||||
|
? 0
|
||||||
|
: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
symbol: benchmarkAssetProfiles[index].symbol,
|
||||||
trend50d: benchmarkTrends[index].trend50d,
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
trend200d: benchmarkTrends[index].trend200d
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
};
|
};
|
||||||
@ -206,17 +218,30 @@ export class BenchmarkService {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataForUser({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate = new Date(),
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: {
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
const marketData: { date: string; value: number }[] = [];
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
|
const days = differenceInDays(endDate, startDate) + 1;
|
||||||
|
const dates = eachDayOfInterval(
|
||||||
|
{
|
||||||
|
start: startDate,
|
||||||
|
end: endDate
|
||||||
|
},
|
||||||
|
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
|
||||||
|
).map((date) => {
|
||||||
|
return resetHours(date);
|
||||||
|
});
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -232,7 +257,7 @@ export class BenchmarkService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: {
|
date: {
|
||||||
gte: startDate
|
in: dates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -266,17 +291,7 @@ export class BenchmarkService {
|
|||||||
return { marketData };
|
return { marketData };
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = Math.round(
|
|
||||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
|
||||||
);
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
for (let marketDataItem of marketDataItems) {
|
for (let marketDataItem of marketDataItems) {
|
||||||
if (i % step !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exchangeRate =
|
const exchangeRate =
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
format(marketDataItem.date, DATE_FORMAT)
|
format(marketDataItem.date, DATE_FORMAT)
|
||||||
@ -299,15 +314,15 @@ export class BenchmarkService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const includesToday = isSameDay(
|
const includesEndDate = isSameDay(
|
||||||
parseDate(last(marketData).date),
|
parseDate(last(marketData).date),
|
||||||
new Date()
|
endDate
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice && !includesToday) {
|
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||||
const exchangeRate =
|
const exchangeRate =
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
format(new Date(), DATE_FORMAT)
|
format(endDate, DATE_FORMAT)
|
||||||
];
|
];
|
||||||
|
|
||||||
const exchangeRateFactor =
|
const exchangeRateFactor =
|
||||||
@ -316,7 +331,7 @@ export class BenchmarkService {
|
|||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
marketData.push({
|
marketData.push({
|
||||||
date: format(new Date(), DATE_FORMAT),
|
date: format(endDate, DATE_FORMAT),
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
@ -407,7 +422,7 @@ export class BenchmarkService {
|
|||||||
private getMarketCondition(
|
private getMarketCondition(
|
||||||
aPerformanceInPercent: number
|
aPerformanceInPercent: number
|
||||||
): Benchmark['marketCondition'] {
|
): Benchmark['marketCondition'] {
|
||||||
if (aPerformanceInPercent === 0) {
|
if (aPerformanceInPercent >= 0) {
|
||||||
return 'ALL_TIME_HIGH';
|
return 'ALL_TIME_HIGH';
|
||||||
} else if (aPerformanceInPercent <= -0.2) {
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
return 'BEAR_MARKET';
|
return 'BEAR_MARKET';
|
||||||
|
16
apps/api/src/app/cache/cache.module.ts
vendored
16
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,4 @@
|
|||||||
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/configuration.module';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [RedisCacheModule]
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
PrismaModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -12,15 +8,7 @@ import { ExportController } from './export.controller';
|
|||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [AccountModule, ApiModule, OrderModule],
|
||||||
AccountModule,
|
|
||||||
ApiModule,
|
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
|
||||||
DataProviderModule,
|
|
||||||
OrderModule,
|
|
||||||
RedisCacheModule
|
|
||||||
],
|
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
providers: [ExportService]
|
providers: [ExportService]
|
||||||
})
|
})
|
||||||
|
@ -95,7 +95,10 @@ export class ExportService {
|
|||||||
: SymbolProfile.symbol
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
user: {
|
||||||
|
settings: { currency: userCurrency }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
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';
|
||||||
|
|
||||||
@ -9,7 +9,11 @@ import { HealthService } from './health.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
imports: [
|
||||||
|
DataEnhancerModule,
|
||||||
|
DataProviderModule,
|
||||||
|
TransformDataSourceInRequestModule
|
||||||
|
],
|
||||||
providers: [HealthService]
|
providers: [HealthService]
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
@ -4,6 +4,8 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/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';
|
||||||
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
|
|||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -13,6 +13,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
|||||||
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/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
@ -27,7 +28,7 @@ import {
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -71,8 +72,12 @@ export class ImportService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = orders.map((order) => {
|
const accounts = orders
|
||||||
return order.Account;
|
.filter(({ Account }) => {
|
||||||
|
return !!Account;
|
||||||
|
})
|
||||||
|
.map(({ Account }) => {
|
||||||
|
return Account;
|
||||||
});
|
});
|
||||||
|
|
||||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
@ -112,12 +117,13 @@ export class ImportService {
|
|||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
|
currency: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
SymbolProfile: assetProfile,
|
||||||
symbolProfileId: assetProfile.id,
|
symbolProfileId: assetProfile.id,
|
||||||
type: 'DIVIDEND',
|
type: 'DIVIDEND',
|
||||||
unitPrice: marketPrice,
|
unitPrice: marketPrice,
|
||||||
@ -261,6 +267,7 @@ export class ImportService {
|
|||||||
{
|
{
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
error,
|
error,
|
||||||
fee,
|
fee,
|
||||||
@ -285,11 +292,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
|
holdings,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -342,6 +349,7 @@ export class ImportService {
|
|||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
@ -357,11 +365,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
|
holdings,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -371,6 +379,7 @@ export class ImportService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
url,
|
url,
|
||||||
|
currency: assetProfile.currency,
|
||||||
comment: assetProfile.comment
|
comment: assetProfile.comment
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
@ -394,9 +403,9 @@ export class ImportService {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol,
|
||||||
|
currency: assetProfile.currency
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -410,6 +419,11 @@ export class ImportService {
|
|||||||
User: { connect: { id: user.id } },
|
User: { connect: { id: user.id } },
|
||||||
userId: user.id
|
userId: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (order.SymbolProfile?.symbol) {
|
||||||
|
// Update symbol that may have been assigned in createOrder()
|
||||||
|
assetProfile.symbol = order.SymbolProfile.symbol;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
@ -420,14 +434,14 @@ export class ImportService {
|
|||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@ -446,15 +460,16 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbols(
|
this.dataGatheringService.gatherSymbols({
|
||||||
uniqueActivities.map(({ date, SymbolProfile }) => {
|
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
@ -521,22 +536,15 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: null,
|
activitiesCount: undefined,
|
||||||
assetSubClass: null,
|
assetClass: undefined,
|
||||||
comment: null,
|
assetSubClass: undefined,
|
||||||
countries: null,
|
countries: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
figi: null,
|
holdings: undefined,
|
||||||
figiComposite: null,
|
|
||||||
figiShareClass: null,
|
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
sectors: undefined,
|
||||||
name: null,
|
updatedAt: undefined
|
||||||
scraperConfiguration: null,
|
|
||||||
sectors: null,
|
|
||||||
symbolMapping: null,
|
|
||||||
updatedAt: undefined,
|
|
||||||
url: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
@ -2,11 +2,11 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.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 { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/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/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
@ -34,6 +34,7 @@ import { InfoService } from './info.service';
|
|||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule,
|
TagModule,
|
||||||
|
TransformDataSourceInResponseModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [LogoController],
|
controllers: [LogoController],
|
||||||
imports: [ConfigurationModule, SymbolProfileModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule
|
||||||
|
],
|
||||||
providers: [LogoService]
|
providers: [LogoService]
|
||||||
})
|
})
|
||||||
export class LogoModule {}
|
export class LogoModule {}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -10,12 +13,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min,
|
||||||
|
Validate
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -39,14 +42,19 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsCurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
customCurrency?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@Validate(IsAfter1970Constraint)
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
|
||||||
|
import { AccountWithPlatform } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Order, Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends Order {
|
||||||
|
Account?: AccountWithPlatform;
|
||||||
error?: ActivityError;
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
SymbolProfile?: EnhancedSymbolProfile;
|
||||||
|
tags?: Tag[];
|
||||||
updateAccountBalance?: boolean;
|
updateAccountBalance?: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import {
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
HEADER_KEY_IMPERSONATION
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -49,22 +53,33 @@ export class OrderController {
|
|||||||
@Delete()
|
@Delete()
|
||||||
@HasPermission(permissions.deleteOrder)
|
@HasPermission(permissions.deleteOrder)
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrders(): Promise<number> {
|
public async deleteOrders(
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
|
): Promise<number> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
|
filters,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@HasPermission(permissions.deleteOrder)
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
const order = await this.orderService.order({ id });
|
const order = await this.orderService.order({
|
||||||
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (!order) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
|
||||||
!order ||
|
|
||||||
order.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -84,12 +99,20 @@ export class OrderController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange?: DateRange,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
|
let endDate: Date;
|
||||||
|
let startDate: Date;
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
({ endDate, startDate } = getInterval(dateRange));
|
||||||
|
}
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
@ -101,9 +124,11 @@ export class OrderController {
|
|||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const { activities, count } = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
startDate,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
@ -120,13 +145,22 @@ export class OrderController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
|
const currency = data.currency;
|
||||||
|
const customCurrency = data.customCurrency;
|
||||||
|
|
||||||
|
if (customCurrency) {
|
||||||
|
data.currency = customCurrency;
|
||||||
|
|
||||||
|
delete data.customCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency: data.currency,
|
currency,
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
},
|
},
|
||||||
@ -145,13 +179,16 @@ export class OrderController {
|
|||||||
if (data.dataSource && !order.isDraft) {
|
if (data.dataSource && !order.isDraft) {
|
||||||
// Gather symbol data in the background, if data source is set
|
// Gather symbol data in the background, if data source is set
|
||||||
// (not MANUAL) and not draft
|
// (not MANUAL) and not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols({
|
||||||
|
dataGatheringItems: [
|
||||||
{
|
{
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
date: order.date,
|
date: order.date,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
]);
|
],
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
@ -176,8 +213,16 @@ export class OrderController {
|
|||||||
const date = parseISO(data.date);
|
const date = parseISO(data.date);
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
|
const customCurrency = data.customCurrency;
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
|
||||||
|
if (customCurrency) {
|
||||||
|
data.currency = customCurrency;
|
||||||
|
|
||||||
|
delete data.customCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
return this.orderService.updateOrder({
|
return this.orderService.updateOrder({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/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/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
@ -23,15 +24,16 @@ import { OrderService } from './order.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedactValuesInResponseModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
],
|
],
|
||||||
providers: [AccountBalanceService, AccountService, OrderService]
|
providers: [AccountBalanceService, AccountService, OrderService]
|
||||||
})
|
})
|
||||||
|
@ -1,17 +1,24 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -21,9 +28,9 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Type as ActivityType
|
Type as ActivityType
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy, uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activities } from './interfaces/activities.interface';
|
import { Activities } from './interfaces/activities.interface';
|
||||||
@ -33,11 +40,45 @@ export class OrderService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async assignTags({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
tags,
|
||||||
|
userId
|
||||||
|
}: { tags: Tag[]; userId: string } & UniqueAsset) {
|
||||||
|
const orders = await this.prismaService.order.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
orders.map(({ id }) =>
|
||||||
|
this.prismaService.order.update({
|
||||||
|
data: {
|
||||||
|
tags: {
|
||||||
|
// The set operation replaces all existing connections with the provided ones
|
||||||
|
set: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -65,7 +106,6 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
let currency = data.currency;
|
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
@ -73,7 +113,6 @@ export class OrderService {
|
|||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
@ -81,7 +120,6 @@ export class OrderService {
|
|||||||
data.id = id;
|
data.id = id;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
|
||||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||||
@ -103,7 +141,8 @@ export class OrderService {
|
|||||||
jobId: getAssetProfileIdentifier({
|
jobId: getAssetProfileIdentifier({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
})
|
}),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -116,7 +155,6 @@ export class OrderService {
|
|||||||
delete data.comment;
|
delete data.comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.currency;
|
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -139,7 +177,8 @@ export class OrderService {
|
|||||||
return { id };
|
return { id };
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
include: { SymbolProfile: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updateAccountBalance === true) {
|
if (updateAccountBalance === true) {
|
||||||
@ -155,12 +194,19 @@ export class OrderService {
|
|||||||
await this.accountService.updateAccountBalance({
|
await this.accountService.updateAccountBalance({
|
||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
|
||||||
userId,
|
userId,
|
||||||
|
currency: data.SymbolProfile.connectOrCreate.create.currency,
|
||||||
date: data.date as Date
|
date: data.date as Date
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: order.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,18 +217,71 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
const [symbolProfile] =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds([
|
||||||
|
order.symbolProfileId
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||||
|
symbolProfile.activitiesCount === 0
|
||||||
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: order.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
public async deleteOrders({
|
||||||
const { count } = await this.prismaService.order.deleteMany({
|
filters,
|
||||||
where
|
userId
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const { activities } = await this.getOrders({
|
||||||
|
filters,
|
||||||
|
userId,
|
||||||
|
includeDrafts: true,
|
||||||
|
userCurrency: undefined,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: activities.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds(
|
||||||
|
activities.map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { activitiesCount, id } of symbolProfiles) {
|
||||||
|
if (activitiesCount === 0) {
|
||||||
|
await this.symbolProfileService.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({ userId })
|
||||||
|
);
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,22 +297,26 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
skip,
|
skip,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
startDate,
|
||||||
take = Number.MAX_SAFE_INTEGER,
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts = false
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
sortColumn?: string;
|
sortColumn?: string;
|
||||||
sortDirection?: Prisma.SortOrder;
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
startDate?: Date;
|
||||||
take?: number;
|
take?: number;
|
||||||
types?: ActivityType[];
|
types?: ActivityType[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
@ -221,18 +324,35 @@ export class OrderService {
|
|||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activities> {
|
}): Promise<Activities> {
|
||||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
{ date: 'asc' }
|
{ date: 'asc' },
|
||||||
|
{ id: 'asc' }
|
||||||
];
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
if (endDate || startDate) {
|
||||||
|
where.AND = [];
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
where.AND.push({ date: { lte: endDate } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
where.AND.push({ date: { gt: startDate } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, ({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const searchQuery = filters?.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
where.accountId = {
|
where.accountId = {
|
||||||
in: filtersByAccount.map(({ id }) => {
|
in: filtersByAccount.map(({ id }) => {
|
||||||
@ -274,6 +394,30 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
||||||
|
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (where.SymbolProfile) {
|
||||||
|
where.SymbolProfile = {
|
||||||
|
AND: [
|
||||||
|
where.SymbolProfile,
|
||||||
|
{
|
||||||
|
OR: searchQueryWhereInput
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
where.SymbolProfile = {
|
||||||
|
OR: searchQueryWhereInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (filtersByTag?.length > 0) {
|
if (filtersByTag?.length > 0) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
@ -285,7 +429,7 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
@ -320,7 +464,32 @@ export class OrderService {
|
|||||||
this.prismaService.order.count({ where })
|
this.prismaService.order.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const uniqueAssets = uniqBy(
|
||||||
|
orders.map(({ SymbolProfile }) => {
|
||||||
|
return {
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||||
|
|
||||||
const activities = orders.map((order) => {
|
const activities = orders.map((order) => {
|
||||||
|
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||||
|
return (
|
||||||
|
dataSource === order.SymbolProfile.dataSource &&
|
||||||
|
symbol === order.SymbolProfile.symbol
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -332,6 +501,7 @@ export class OrderService {
|
|||||||
order.SymbolProfile.currency,
|
order.SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
// TODO: Use exchange rate of date
|
// TODO: Use exchange rate of date
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
@ -344,6 +514,26 @@ export class OrderService {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getStatisticsByCurrency(
|
||||||
|
currency: EnhancedSymbolProfile['currency']
|
||||||
|
): Promise<{
|
||||||
|
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
|
||||||
|
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
}> {
|
||||||
|
const { _count, _min } = await this.prismaService.order.aggregate({
|
||||||
|
_count: true,
|
||||||
|
_min: {
|
||||||
|
date: true
|
||||||
|
},
|
||||||
|
where: { SymbolProfile: { currency } }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activitiesCount: _count as number,
|
||||||
|
dateOfFirstActivity: _min.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order | null> {
|
): Promise<Order | null> {
|
||||||
@ -388,19 +578,22 @@ export class OrderService {
|
|||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols({
|
||||||
|
dataGatheringItems: [
|
||||||
{
|
{
|
||||||
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
dataSource:
|
||||||
|
data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||||
date: <Date>data.date,
|
date: <Date>data.date,
|
||||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||||
}
|
}
|
||||||
]);
|
],
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -411,7 +604,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
const order = await this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft,
|
isDraft,
|
||||||
@ -423,6 +616,15 @@ export class OrderService {
|
|||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: order.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async orders(params: {
|
private async orders(params: {
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -9,12 +12,12 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min,
|
||||||
|
Validate
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -38,13 +41,18 @@ export class UpdateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsCurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
customCurrency?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@Validate(IsAfter1970Constraint)
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
export class CreatePlatformDto {
|
export class CreatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
export class UpdatePlatformDto {
|
export class UpdatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||||
|
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||||
|
|
||||||
|
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||||
|
protected calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): PortfolioSnapshot {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode = false,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step = 1,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
export const activityDummyData = {
|
||||||
|
accountId: undefined,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
currency: undefined,
|
||||||
|
feeInBaseCurrency: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isDraft: false,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: undefined,
|
||||||
|
value: undefined,
|
||||||
|
valueInBaseCurrency: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export const symbolProfileDummyData = {
|
||||||
|
activitiesCount: undefined,
|
||||||
|
assetClass: undefined,
|
||||||
|
assetSubClass: undefined,
|
||||||
|
countries: [],
|
||||||
|
createdAt: undefined,
|
||||||
|
holdings: [],
|
||||||
|
id: undefined,
|
||||||
|
sectors: [],
|
||||||
|
updatedAt: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userDummyData = {
|
||||||
|
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||||
|
};
|
@ -0,0 +1,81 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
|
||||||
|
|
||||||
|
export enum PerformanceCalculationType {
|
||||||
|
MWR = 'MWR', // Money-Weighted Rate of Return
|
||||||
|
TWR = 'TWR' // Time-Weighted Rate of Return
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortfolioCalculatorFactory {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly currentRateService: CurrentRateService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createCalculator({
|
||||||
|
accountBalanceItems = [],
|
||||||
|
activities,
|
||||||
|
calculationType,
|
||||||
|
currency,
|
||||||
|
dateRange = 'max',
|
||||||
|
hasFilters,
|
||||||
|
isExperimentalFeatures = false,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
accountBalanceItems?: HistoricalDataItem[];
|
||||||
|
activities: Activity[];
|
||||||
|
calculationType: PerformanceCalculationType;
|
||||||
|
currency: string;
|
||||||
|
dateRange?: DateRange;
|
||||||
|
hasFilters: boolean;
|
||||||
|
isExperimentalFeatures?: boolean;
|
||||||
|
userId: string;
|
||||||
|
}): PortfolioCalculator {
|
||||||
|
const useCache = !hasFilters && isExperimentalFeatures;
|
||||||
|
|
||||||
|
switch (calculationType) {
|
||||||
|
case PerformanceCalculationType.MWR:
|
||||||
|
return new MWRPortfolioCalculator({
|
||||||
|
accountBalanceItems,
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
dateRange,
|
||||||
|
useCache,
|
||||||
|
userId,
|
||||||
|
configurationService: this.configurationService,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService,
|
||||||
|
redisCacheService: this.redisCacheService
|
||||||
|
});
|
||||||
|
case PerformanceCalculationType.TWR:
|
||||||
|
return new TWRPortfolioCalculator({
|
||||||
|
accountBalanceItems,
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
dateRange,
|
||||||
|
useCache,
|
||||||
|
userId,
|
||||||
|
configurationService: this.configurationService,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService,
|
||||||
|
redisCacheService: this.redisCacheService
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid calculation type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1107
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
1107
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,22 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -29,62 +54,84 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell in two activities', async () => {
|
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-22',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.55),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(142.9)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.65),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(136.6)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(136.6)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-22'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2021-11-22')
|
start: parseDate('2021-11-22')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -97,7 +144,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
@ -121,6 +168,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('3.2'),
|
fee: new Big('3.2'),
|
||||||
|
feeInBaseCurrency: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
@ -140,6 +188,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 148.9,
|
marketPriceInBaseCurrency: 148.9,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
'285.80000000000000396627'
|
'285.80000000000000396627'
|
||||||
@ -148,8 +197,12 @@ describe('PortfolioCalculator', () => {
|
|||||||
valueInBaseCurrency: new Big('0')
|
valueInBaseCurrency: new Big('0')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('0'),
|
totalInvestment: new Big('0'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-22',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.55),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(142.9)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.65),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(136.6)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-22'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2021-11-22')
|
start: parseDate('2021-11-22')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
@ -110,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('3.2'),
|
fee: new Big('3.2'),
|
||||||
|
feeInBaseCurrency: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
@ -129,14 +173,19 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 148.9,
|
marketPriceInBaseCurrency: 148.9,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('285.8'),
|
timeWeightedInvestment: new Big('285.8'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||||
transactionCount: 2,
|
transactionCount: 2,
|
||||||
valueInBaseCurrency: new Big('0')
|
valueInBaseCurrency: new Big('0')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('0'),
|
totalInvestment: new Big('0'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy', async () => {
|
it.only('with BALN.SW buy', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.55),
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(136.6)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2021-11-30')
|
start: parseDate('2021-11-30')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2021-11-30')
|
parseDate('2021-11-30')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -75,7 +114,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('297.8'),
|
currentValueInBaseCurrency: new Big('297.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
@ -99,6 +138,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1.55'),
|
fee: new Big('1.55'),
|
||||||
|
feeInBaseCurrency: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
@ -118,14 +158,19 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 148.9,
|
marketPriceInBaseCurrency: 148.9,
|
||||||
quantity: new Big('2'),
|
quantity: new Big('2'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('273.2'),
|
timeWeightedInvestment: new Big('273.2'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
transactionCount: 1,
|
transactionCount: 1,
|
||||||
valueInBaseCurrency: new Big('297.8')
|
valueInBaseCurrency: new Big('297.8')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('1.55'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('273.2'),
|
totalInvestment: new Big('273.2'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('273.2')
|
totalInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -1,12 +1,23 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
() => {
|
() => {
|
||||||
@ -30,10 +50,15 @@ jest.mock(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -42,51 +67,69 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BTCUSD buy and sell partially', async () => {
|
it.only('with BTCUSD buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'USD',
|
|
||||||
date: '2015-01-01',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(320.43)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'USD',
|
|
||||||
date: '2017-12-31',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(14156.4)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2015-01-01'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
symbol: 'BTCUSD'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 320.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2017-12-31'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
symbol: 'BTCUSD'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 14156.4
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2015-01-01')
|
start: parseDate('2015-01-01')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2015-01-01')
|
parseDate('2015-01-01')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -99,7 +142,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
@ -123,6 +166,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
@ -146,7 +190,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
),
|
),
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
tags: undefined,
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
'636.79469348020066587024'
|
'636.79469348020066587024'
|
||||||
@ -155,8 +199,12 @@ describe('PortfolioCalculator', () => {
|
|||||||
valueInBaseCurrency: new Big('13298.425356')
|
valueInBaseCurrency: new Big('13298.425356')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('320.43'),
|
totalInvestment: new Big('320.43'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957')
|
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -0,0 +1,158 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with fee activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-09-01'),
|
||||||
|
fee: 49,
|
||||||
|
quantity: 0,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Account Opening Fee',
|
||||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
||||||
|
},
|
||||||
|
type: 'FEE',
|
||||||
|
unitPrice: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2021-11-30')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('0'),
|
||||||
|
grossPerformancePercentage: new Big('0'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
hasErrors: true,
|
||||||
|
netPerformance: new Big('0'),
|
||||||
|
netPerformancePercentage: new Big('0'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('49'),
|
||||||
|
feeInBaseCurrency: new Big('49'),
|
||||||
|
firstBuyDate: '2021-09-01',
|
||||||
|
grossPerformance: null,
|
||||||
|
grossPerformancePercentage: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: null,
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
marketPrice: null,
|
||||||
|
marketPriceInBaseCurrency: 0,
|
||||||
|
netPerformance: null,
|
||||||
|
netPerformancePercentage: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
netPerformanceWithCurrencyEffect: null,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('0'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('49'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,12 +1,23 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock(
|
jest.mock(
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
() => {
|
() => {
|
||||||
@ -30,10 +50,15 @@ jest.mock(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -42,40 +67,54 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with GOOGL buy', async () => {
|
it.only('with GOOGL buy', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'USD',
|
|
||||||
date: '2023-01-03',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1),
|
|
||||||
name: 'Alphabet Inc.',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'GOOGL',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(89.12)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2023-01-03'),
|
||||||
|
fee: 1,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Alphabet Inc.',
|
||||||
|
symbol: 'GOOGL'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 89.12
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2023-01-03')
|
start: parseDate('2023-01-03')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2023-01-03')
|
parseDate('2023-01-03')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -88,7 +127,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('103.10483'),
|
currentValueInBaseCurrency: new Big('103.10483'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27.33'),
|
grossPerformance: new Big('27.33'),
|
||||||
@ -112,6 +151,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1'),
|
fee: new Big('1'),
|
||||||
|
feeInBaseCurrency: new Big('0.9238'),
|
||||||
firstBuyDate: '2023-01-03',
|
firstBuyDate: '2023-01-03',
|
||||||
grossPerformance: new Big('27.33'),
|
grossPerformance: new Big('27.33'),
|
||||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
@ -131,15 +171,19 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 103.10483,
|
marketPriceInBaseCurrency: 103.10483,
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'GOOGL',
|
symbol: 'GOOGL',
|
||||||
tags: undefined,
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('89.12'),
|
timeWeightedInvestment: new Big('89.12'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
transactionCount: 1,
|
transactionCount: 1,
|
||||||
valueInBaseCurrency: new Big('103.10483')
|
valueInBaseCurrency: new Big('103.10483')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('89.12'),
|
totalInvestment: new Big('89.12'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('82.329056')
|
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -0,0 +1,158 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with item activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-01-01'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Penthouse Apartment',
|
||||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
||||||
|
},
|
||||||
|
type: 'ITEM',
|
||||||
|
unitPrice: 500000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2022-01-01')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('0'),
|
||||||
|
grossPerformancePercentage: new Big('0'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
hasErrors: true,
|
||||||
|
netPerformance: new Big('0'),
|
||||||
|
netPerformancePercentage: new Big('0'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('500000'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
|
firstBuyDate: '2022-01-01',
|
||||||
|
grossPerformance: null,
|
||||||
|
grossPerformancePercentage: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: null,
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
marketPrice: null,
|
||||||
|
marketPriceInBaseCurrency: 500000,
|
||||||
|
netPerformance: null,
|
||||||
|
netPerformancePercentage: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
netPerformanceWithCurrencyEffect: null,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('0'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,108 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with liability activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2023-01-01'), // Date in future
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Loan',
|
||||||
|
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
||||||
|
},
|
||||||
|
type: 'LIABILITY',
|
||||||
|
unitPrice: 3000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
const liabilitiesInBaseCurrency =
|
||||||
|
await portfolioCalculator.getLiabilitiesInBaseCurrency();
|
||||||
|
|
||||||
|
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,165 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with MSFT buy', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-09-16'),
|
||||||
|
fee: 19,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 298.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-16'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: 0.62
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2023-07-10')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toMatchObject({
|
||||||
|
errors: [],
|
||||||
|
hasErrors: false,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('298.58'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0.62'),
|
||||||
|
dividendInBaseCurrency: new Big('0.62'),
|
||||||
|
fee: new Big('19'),
|
||||||
|
firstBuyDate: '2021-09-16',
|
||||||
|
investment: new Big('298.58'),
|
||||||
|
investmentWithCurrencyEffect: new Big('298.58'),
|
||||||
|
marketPrice: 331.83,
|
||||||
|
marketPriceInBaseCurrency: 331.83,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'MSFT',
|
||||||
|
tags: [],
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('19'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('298.58'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('298.58'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,124 @@
|
|||||||
|
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it('with no orders', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities: [],
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = subDays(new Date(Date.now()), 10);
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({ start });
|
||||||
|
|
||||||
|
const portfolioSnapshot =
|
||||||
|
await portfolioCalculator.computeSnapshot(start);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{
|
||||||
|
date: '2021-12-01',
|
||||||
|
investment: 0
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,11 +1,22 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2022-03-07',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(1.3),
|
|
||||||
name: 'Novartis AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(75.8)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2022-04-08',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(2.95),
|
|
||||||
name: 'Novartis AG',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(85.73)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-03-07'),
|
||||||
|
fee: 1.3,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 2.95,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 85.73
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2022-03-07')
|
start: parseDate('2022-03-07')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2022-03-07')
|
parseDate('2022-03-07')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('87.8'),
|
currentValueInBaseCurrency: new Big('87.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
@ -110,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('4.25'),
|
fee: new Big('4.25'),
|
||||||
|
feeInBaseCurrency: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
@ -129,6 +173,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 87.8,
|
marketPriceInBaseCurrency: 87.8,
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
'145.10285714285714285714'
|
'145.10285714285714285714'
|
||||||
@ -137,8 +182,12 @@ describe('PortfolioCalculator', () => {
|
|||||||
valueInBaseCurrency: new Big('87.8')
|
valueInBaseCurrency: new Big('87.8')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('4.25'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('75.80'),
|
totalInvestment: new Big('75.80'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('75.80')
|
totalInvestmentWithCurrencyEffect: new Big('75.80'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData,
|
||||||
|
userDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||||
|
return RedisCacheServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell', async () => {
|
it.only('with NOVN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2022-03-07',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Novartis AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(75.8)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'CHF',
|
|
||||||
date: '2022-04-08',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Novartis AG',
|
|
||||||
quantity: new Big(2),
|
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: new Big(85.73)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-03-07'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 85.73
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF',
|
||||||
|
hasFilters: false,
|
||||||
|
userId: userDummyData.id
|
||||||
|
});
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
start: parseDate('2022-03-07')
|
start: parseDate('2022-03-07')
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
parseDate('2022-03-07')
|
parseDate('2022-03-07')
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -93,6 +136,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
netPerformanceInPercentage: 0,
|
netPerformanceInPercentage: 0,
|
||||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||||
netPerformanceWithCurrencyEffect: 0,
|
netPerformanceWithCurrencyEffect: 0,
|
||||||
|
netWorth: 151.6,
|
||||||
|
totalAccountBalance: 0,
|
||||||
totalInvestment: 151.6,
|
totalInvestment: 151.6,
|
||||||
totalInvestmentValueWithCurrencyEffect: 151.6,
|
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||||
value: 151.6,
|
value: 151.6,
|
||||||
@ -106,13 +151,15 @@ describe('PortfolioCalculator', () => {
|
|||||||
netPerformanceInPercentage: 13.100263852242744,
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||||
netPerformanceWithCurrencyEffect: 19.86,
|
netPerformanceWithCurrencyEffect: 19.86,
|
||||||
|
netWorth: 0,
|
||||||
|
totalAccountBalance: 0,
|
||||||
totalInvestment: 0,
|
totalInvestment: 0,
|
||||||
totalInvestmentValueWithCurrencyEffect: 0,
|
totalInvestmentValueWithCurrencyEffect: 0,
|
||||||
value: 0,
|
value: 0,
|
||||||
valueWithCurrencyEffect: 0
|
valueWithCurrencyEffect: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(portfolioSnapshot).toEqual({
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
@ -136,6 +183,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
dividend: new Big('0'),
|
dividend: new Big('0'),
|
||||||
dividendInBaseCurrency: new Big('0'),
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
|
feeInBaseCurrency: new Big('0'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
@ -155,14 +203,19 @@ describe('PortfolioCalculator', () => {
|
|||||||
marketPriceInBaseCurrency: 87.8,
|
marketPriceInBaseCurrency: 87.8,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
tags: [],
|
||||||
timeWeightedInvestment: new Big('151.6'),
|
timeWeightedInvestment: new Big('151.6'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||||
transactionCount: 2,
|
transactionCount: 2,
|
||||||
valueInBaseCurrency: new Big('0')
|
valueInBaseCurrency: new Big('0')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
totalInvestment: new Big('0'),
|
totalInvestment: new Big('0'),
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
@ -0,0 +1,37 @@
|
|||||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
let redisCacheService: RedisCacheService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
redisCacheService = new RedisCacheService(null, null);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
configurationService,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
redisCacheService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip('Skip empty test', () => 1);
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
|||||||
|
|
||||||
function mockGetValue(symbol: string, date: Date) {
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
switch (symbol) {
|
switch (symbol) {
|
||||||
|
case '55196015-1365-4560-aa60-8751ae6d18f8':
|
||||||
|
if (isSameDay(parseDate('2022-01-31'), date)) {
|
||||||
|
return { marketPrice: 3000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
case 'BALN.SW':
|
case 'BALN.SW':
|
||||||
if (isSameDay(parseDate('2021-11-12'), date)) {
|
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||||
return { marketPrice: 146 };
|
return { marketPrice: 146 };
|
||||||
|
@ -34,7 +34,7 @@ export class CurrentRateService {
|
|||||||
}: GetValuesParams): Promise<GetValuesObject> {
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
const dataProviderInfos: DataProviderInfo[] = [];
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
|
|
||||||
const includeToday =
|
const includesToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
@ -43,7 +43,7 @@ export class CurrentRateService {
|
|||||||
const quoteErrors: ResponseError['errors'] = [];
|
const quoteErrors: ResponseError['errors'] = [];
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
|
|
||||||
if (includeToday) {
|
if (includesToday) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
export interface CurrentPositions extends ResponseError {
|
|
||||||
currentValueInBaseCurrency: Big;
|
|
||||||
grossPerformance: Big;
|
|
||||||
grossPerformanceWithCurrencyEffect: Big;
|
|
||||||
grossPerformancePercentage: Big;
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: Big;
|
|
||||||
netAnnualizedPerformance?: Big;
|
|
||||||
netAnnualizedPerformanceWithCurrencyEffect?: Big;
|
|
||||||
netPerformance: Big;
|
|
||||||
netPerformanceWithCurrencyEffect: Big;
|
|
||||||
netPerformancePercentage: Big;
|
|
||||||
netPerformancePercentageWithCurrencyEffect: Big;
|
|
||||||
positions: TimelinePosition[];
|
|
||||||
totalInvestment: Big;
|
|
||||||
}
|
|
@ -1,17 +1,19 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import {
|
import {
|
||||||
DataProviderInfo,
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Account, Tag } from '@prisma/client';
|
import { Account, Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioHoldingDetail {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
dataProviderInfo: DataProviderInfo;
|
dataProviderInfo: DataProviderInfo;
|
||||||
dividendInBaseCurrency: number;
|
dividendInBaseCurrency: number;
|
||||||
|
dividendYieldPercent: number;
|
||||||
|
dividendYieldPercentWithCurrencyEffect: number;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
@ -27,16 +29,10 @@ export interface PortfolioPositionDetail {
|
|||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
netPerformancePercentWithCurrencyEffect: number;
|
netPerformancePercentWithCurrencyEffect: number;
|
||||||
netPerformanceWithCurrencyEffect: number;
|
netPerformanceWithCurrencyEffect: number;
|
||||||
orders: OrderWithAccount[];
|
orders: Activity[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
SymbolProfile: EnhancedSymbolProfile;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataContainer {
|
|
||||||
isAllTimeHigh: boolean;
|
|
||||||
isAllTimeLow: boolean;
|
|
||||||
items: HistoricalDataItem[];
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { PortfolioOrder } from './portfolio-order.interface';
|
import { PortfolioOrder } from './portfolio-order.interface';
|
||||||
|
|
@ -1,15 +1,12 @@
|
|||||||
import { DataSource, Tag, Type as ActivityType } from '@prisma/client';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
|
||||||
currency: string;
|
|
||||||
date: string;
|
date: string;
|
||||||
dataSource: DataSource;
|
|
||||||
fee: Big;
|
fee: Big;
|
||||||
name: string;
|
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
SymbolProfile: Pick<
|
||||||
tags?: Tag[];
|
Activity['SymbolProfile'],
|
||||||
type: ActivityType;
|
'currency' | 'dataSource' | 'name' | 'symbol'
|
||||||
|
>;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import { Position } from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
export interface PortfolioPositions {
|
|
||||||
positions: Position[];
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import { DataSource, Tag } from '@prisma/client';
|
import { DataSource, Tag } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
averagePrice: Big;
|
averagePrice: Big;
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||||
|
|
||||||
export interface TransactionPoint {
|
export interface TransactionPoint {
|
||||||
date: string;
|
date: string;
|
||||||
|
fees: Big;
|
||||||
|
interest: Big;
|
||||||
items: TransactionPointSymbol[];
|
items: TransactionPointSymbol[];
|
||||||
|
liabilities: Big;
|
||||||
|
valuables: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,118 +0,0 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
|
||||||
() => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
|
||||||
return ExchangeRateDataServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with MSFT buy', async () => {
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: [
|
|
||||||
{
|
|
||||||
currency: 'USD',
|
|
||||||
date: '2021-09-16',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(19),
|
|
||||||
name: 'Microsoft Inc.',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'MSFT',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(298.58)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currency: 'USD',
|
|
||||||
date: '2021-11-16',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Microsoft Inc.',
|
|
||||||
quantity: new Big(1),
|
|
||||||
symbol: 'MSFT',
|
|
||||||
type: 'DIVIDEND',
|
|
||||||
unitPrice: new Big(0.62)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
|
||||||
parseDate('2023-07-10')
|
|
||||||
);
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(currentPositions).toMatchObject({
|
|
||||||
errors: [],
|
|
||||||
hasErrors: false,
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('298.58'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0.62'),
|
|
||||||
dividendInBaseCurrency: new Big('0.62'),
|
|
||||||
fee: new Big('19'),
|
|
||||||
firstBuyDate: '2021-09-16',
|
|
||||||
investment: new Big('298.58'),
|
|
||||||
investmentWithCurrencyEffect: new Big('298.58'),
|
|
||||||
marketPrice: 331.83,
|
|
||||||
marketPriceInBaseCurrency: 331.83,
|
|
||||||
quantity: new Big('1'),
|
|
||||||
symbol: 'MSFT',
|
|
||||||
tags: undefined,
|
|
||||||
transactionCount: 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalInvestment: new Big('298.58'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('298.58')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,86 +0,0 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it('with no orders', async () => {
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'CHF',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
|
||||||
new Date()
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big(0),
|
|
||||||
positions: [],
|
|
||||||
totalInvestment: new Big(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,84 +0,0 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
|
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioCalculator
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculator
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculator
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercent: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculator
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercent: new Big(0.05)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.05);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculator
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercent: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,14 +1,16 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -26,6 +28,11 @@ import {
|
|||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
hasReadRestrictedAccessPermission,
|
||||||
|
isRestrictedView,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@ -33,12 +40,14 @@ import type {
|
|||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@ -46,12 +55,13 @@ 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 Big from 'big.js';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -77,17 +87,12 @@ export class PortfolioController {
|
|||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('withLiabilities') withLiabilitiesParam = 'false'
|
@Query('withMarkets') withMarketsParam = 'false'
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
const withLiabilities = withLiabilitiesParam === 'true';
|
const withMarkets = withMarketsParam === 'true';
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
const hasReadRestrictedAccessPermission =
|
|
||||||
this.userService.hasReadRestrictedAccessPermission({
|
|
||||||
impersonationId,
|
|
||||||
user: this.request.user
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
@ -104,7 +109,7 @@ export class PortfolioController {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
withLiabilities,
|
withMarkets,
|
||||||
userId: this.request.user.id,
|
userId: this.request.user.id,
|
||||||
withSummary: true
|
withSummary: true
|
||||||
});
|
});
|
||||||
@ -116,8 +121,11 @@ export class PortfolioController {
|
|||||||
let portfolioSummary = summary;
|
let portfolioSummary = summary;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasReadRestrictedAccessPermission ||
|
hasReadRestrictedAccessPermission({
|
||||||
this.userService.isRestrictedView(this.request.user)
|
impersonationId,
|
||||||
|
user: this.request.user
|
||||||
|
}) ||
|
||||||
|
isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
const totalInvestment = Object.values(holdings)
|
const totalInvestment = Object.values(holdings)
|
||||||
.map(({ investment }) => {
|
.map(({ investment }) => {
|
||||||
@ -127,14 +135,19 @@ export class PortfolioController {
|
|||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter(({ assetClass, assetSubClass }) => {
|
.filter(({ assetClass, assetSubClass }) => {
|
||||||
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
return (
|
||||||
|
assetClass !== AssetClass.LIQUIDITY &&
|
||||||
|
assetSubClass !== AssetSubClass.CASH
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map(({ valueInBaseCurrency }) => {
|
.map(({ valueInBaseCurrency }) => {
|
||||||
return valueInBaseCurrency;
|
return valueInBaseCurrency;
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => {
|
||||||
|
return a + b;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPosition.investment =
|
portfolioPosition.investment =
|
||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.valueInPercentage =
|
portfolioPosition.valueInPercentage =
|
||||||
@ -152,27 +165,30 @@ export class PortfolioController {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
hasDetails === false ||
|
hasDetails === false ||
|
||||||
hasReadRestrictedAccessPermission ||
|
hasReadRestrictedAccessPermission({
|
||||||
this.userService.isRestrictedView(this.request.user)
|
impersonationId,
|
||||||
|
user: this.request.user
|
||||||
|
}) ||
|
||||||
|
isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
portfolioSummary = nullifyValuesInObject(summary, [
|
portfolioSummary = nullifyValuesInObject(summary, [
|
||||||
'cash',
|
'cash',
|
||||||
'committedFunds',
|
'committedFunds',
|
||||||
'currentGrossPerformance',
|
|
||||||
'currentGrossPerformanceWithCurrencyEffect',
|
|
||||||
'currentNetPerformance',
|
|
||||||
'currentNetPerformanceWithCurrencyEffect',
|
|
||||||
'currentNetWorth',
|
'currentNetWorth',
|
||||||
'currentValue',
|
'currentValueInBaseCurrency',
|
||||||
'dividendInBaseCurrency',
|
'dividendInBaseCurrency',
|
||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
'filteredValueInBaseCurrency',
|
'filteredValueInBaseCurrency',
|
||||||
'fireWealth',
|
'fireWealth',
|
||||||
|
'grossPerformance',
|
||||||
|
'grossPerformanceWithCurrencyEffect',
|
||||||
'interest',
|
'interest',
|
||||||
'items',
|
'items',
|
||||||
'liabilities',
|
'liabilities',
|
||||||
|
'netPerformance',
|
||||||
|
'netPerformanceWithCurrencyEffect',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalInvestment',
|
'totalInvestment',
|
||||||
'totalSell',
|
'totalSell',
|
||||||
@ -184,15 +200,16 @@ export class PortfolioController {
|
|||||||
holdings[symbol] = {
|
holdings[symbol] = {
|
||||||
...portfolioPosition,
|
...portfolioPosition,
|
||||||
assetClass:
|
assetClass:
|
||||||
hasDetails || portfolioPosition.assetClass === 'CASH'
|
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
|
||||||
? portfolioPosition.assetClass
|
? portfolioPosition.assetClass
|
||||||
: undefined,
|
: undefined,
|
||||||
assetSubClass:
|
assetSubClass:
|
||||||
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
|
||||||
? portfolioPosition.assetSubClass
|
? portfolioPosition.assetSubClass
|
||||||
: undefined,
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
holdings: hasDetails ? portfolioPosition.holdings : [],
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
marketsAdvanced: hasDetails
|
marketsAdvanced: hasDetails
|
||||||
? portfolioPosition.marketsAdvanced
|
? portfolioPosition.marketsAdvanced
|
||||||
@ -220,12 +237,6 @@ export class PortfolioController {
|
|||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDividends> {
|
): Promise<PortfolioDividends> {
|
||||||
const hasReadRestrictedAccessPermission =
|
|
||||||
this.userService.hasReadRestrictedAccessPermission({
|
|
||||||
impersonationId,
|
|
||||||
user: this.request.user
|
|
||||||
});
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
@ -236,8 +247,12 @@ export class PortfolioController {
|
|||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange);
|
||||||
|
|
||||||
const { activities } = await this.orderService.getOrders({
|
const { activities } = await this.orderService.getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
|
startDate,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
types: ['DIVIDEND']
|
types: ['DIVIDEND']
|
||||||
@ -245,13 +260,15 @@ export class PortfolioController {
|
|||||||
|
|
||||||
let dividends = await this.portfolioService.getDividends({
|
let dividends = await this.portfolioService.getDividends({
|
||||||
activities,
|
activities,
|
||||||
dateRange,
|
|
||||||
groupBy
|
groupBy
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasReadRestrictedAccessPermission ||
|
hasReadRestrictedAccessPermission({
|
||||||
this.userService.isRestrictedView(this.request.user)
|
impersonationId,
|
||||||
|
user: this.request.user
|
||||||
|
}) ||
|
||||||
|
isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
const maxDividend = dividends.reduce(
|
const maxDividend = dividends.reduce(
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
@ -286,6 +303,7 @@ export class PortfolioController {
|
|||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('holdingType') filterByHoldingType?: string,
|
@Query('holdingType') filterByHoldingType?: string,
|
||||||
@Query('query') filterBySearchQuery?: string,
|
@Query('query') filterBySearchQuery?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioHoldingsResponse> {
|
): Promise<PortfolioHoldingsResponse> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
@ -297,6 +315,7 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails({
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
@ -315,12 +334,6 @@ export class PortfolioController {
|
|||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
const hasReadRestrictedAccessPermission =
|
|
||||||
this.userService.hasReadRestrictedAccessPermission({
|
|
||||||
impersonationId,
|
|
||||||
user: this.request.user
|
|
||||||
});
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
@ -336,8 +349,11 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasReadRestrictedAccessPermission ||
|
hasReadRestrictedAccessPermission({
|
||||||
this.userService.isRestrictedView(this.request.user)
|
impersonationId,
|
||||||
|
user: this.request.user
|
||||||
|
}) ||
|
||||||
|
isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
const maxInvestment = investments.reduce(
|
const maxInvestment = investments.reduce(
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
@ -382,17 +398,9 @@ export class PortfolioController {
|
|||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
|
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||||
@Query('withItems') withItemsParam = 'false'
|
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||||
const withItems = withItemsParam === 'true';
|
|
||||||
|
|
||||||
const hasReadRestrictedAccessPermission =
|
|
||||||
this.userService.hasReadRestrictedAccessPermission({
|
|
||||||
impersonationId,
|
|
||||||
user: this.request.user
|
|
||||||
});
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -405,14 +413,16 @@ export class PortfolioController {
|
|||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
withExcludedAccounts,
|
withExcludedAccounts,
|
||||||
withItems,
|
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasReadRestrictedAccessPermission ||
|
hasReadRestrictedAccessPermission({
|
||||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
impersonationId,
|
||||||
this.userService.isRestrictedView(this.request.user)
|
user: this.request.user
|
||||||
|
}) ||
|
||||||
|
isRestrictedView(this.request.user) ||
|
||||||
|
this.request.user.Settings.settings.viewMode === 'ZEN'
|
||||||
) {
|
) {
|
||||||
performanceInformation.chart = performanceInformation.chart.map(
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
({
|
({
|
||||||
@ -440,10 +450,14 @@ export class PortfolioController {
|
|||||||
.div(performanceInformation.performance.totalInvestment)
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
.toNumber(),
|
.toNumber(),
|
||||||
valueInPercentage:
|
valueInPercentage:
|
||||||
performanceInformation.performance.currentValue === 0
|
performanceInformation.performance.currentValueInBaseCurrency ===
|
||||||
|
0
|
||||||
? 0
|
? 0
|
||||||
: new Big(value)
|
: new Big(value)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(
|
||||||
|
performanceInformation.performance
|
||||||
|
.currentValueInBaseCurrency
|
||||||
|
)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -452,12 +466,12 @@ export class PortfolioController {
|
|||||||
performanceInformation.performance = nullifyValuesInObject(
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
[
|
[
|
||||||
'currentGrossPerformance',
|
|
||||||
'currentGrossPerformanceWithCurrencyEffect',
|
|
||||||
'currentNetPerformance',
|
|
||||||
'currentNetPerformanceWithCurrencyEffect',
|
|
||||||
'currentNetWorth',
|
'currentNetWorth',
|
||||||
'currentValue',
|
'currentValueInBaseCurrency',
|
||||||
|
'grossPerformance',
|
||||||
|
'grossPerformanceWithCurrencyEffect',
|
||||||
|
'netPerformance',
|
||||||
|
'netPerformanceWithCurrencyEffect',
|
||||||
'totalInvestment'
|
'totalInvestment'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -474,48 +488,19 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
performanceInformation.performance = nullifyValuesInObject(
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
['currentNetPerformance', 'currentNetPerformancePercent']
|
['netPerformance']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return performanceInformation;
|
return performanceInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getPositions(
|
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
||||||
@Query('accounts') filterByAccounts?: string,
|
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
|
||||||
@Query('query') filterBySearchQuery?: string,
|
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
|
||||||
@Query('tags') filterByTags?: string
|
|
||||||
): Promise<PortfolioPositions> {
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
||||||
filterByAccounts,
|
|
||||||
filterByAssetClasses,
|
|
||||||
filterBySearchQuery,
|
|
||||||
filterByTags
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.portfolioService.getPositions({
|
|
||||||
dateRange,
|
|
||||||
filters,
|
|
||||||
impersonationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
const access = await this.accessService.access({ id: accessId });
|
const access = await this.accessService.access({ id: accessId });
|
||||||
const user = await this.userService.user({
|
|
||||||
id: access.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!access) {
|
if (!access) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -525,6 +510,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
|
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
@ -532,7 +522,8 @@ export class PortfolioController {
|
|||||||
const { holdings } = await this.portfolioService.getDetails({
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
impersonationId: access.userId,
|
impersonationId: access.userId,
|
||||||
userId: user.id
|
userId: user.id,
|
||||||
|
withMarkets: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
@ -580,25 +571,25 @@ export class PortfolioController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioHoldingDetail> {
|
||||||
const position = await this.portfolioService.getPosition(
|
const holding = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (!holding) {
|
||||||
return position;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
StatusCodes.NOT_FOUND
|
StatusCodes.NOT_FOUND
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return holding;
|
||||||
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@ -619,4 +610,36 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
|
@Put('position/:dataSource/:symbol/tags')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async updateHoldingTags(
|
||||||
|
@Body() data: UpdateHoldingTagsDto,
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
const holding = await this.portfolioService.getPosition(
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!holding) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.portfolioService.updateTags({
|
||||||
|
dataSource,
|
||||||
|
impersonationId,
|
||||||
|
symbol,
|
||||||
|
tags: data.tags,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,11 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
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 { 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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
@ -15,6 +19,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
|
|||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
@ -34,13 +39,18 @@ import { RulesService } from './rules.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedactValuesInResponseModule,
|
||||||
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AccountBalanceService,
|
AccountBalanceService,
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -17,8 +17,16 @@ export class RulesService {
|
|||||||
return rule.getSettings(aUserSettings)?.isActive;
|
return rule.getSettings(aUserSettings)?.isActive;
|
||||||
})
|
})
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
|
const { evaluation, value } = rule.evaluate(
|
||||||
return { ...evaluationResult, name: rule.getName() };
|
rule.getSettings(aUserSettings)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
evaluation,
|
||||||
|
value,
|
||||||
|
key: rule.getKey(),
|
||||||
|
name: rule.getName()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { IsArray } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateHoldingTagsDto {
|
||||||
|
@IsArray()
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
|
|
||||||
|
export const RedisCacheServiceMock = {
|
||||||
|
get: (key: string): Promise<string> => {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
getPortfolioSnapshotKey: (userId: string): string => {
|
||||||
|
return `portfolio-snapshot-${userId}`;
|
||||||
|
},
|
||||||
|
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
|
||||||
|
return Promise.resolve(value);
|
||||||
|
}
|
||||||
|
};
|
@ -24,6 +24,10 @@ export class RedisCacheService {
|
|||||||
return this.cache.get(key);
|
return this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
|
||||||
|
return `portfolio-snapshot-${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
interpolate
|
interpolate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||||
|
|
||||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -14,7 +16,9 @@ import * as path from 'path';
|
|||||||
export class SitemapController {
|
export class SitemapController {
|
||||||
public sitemapXml = '';
|
public sitemapXml = '';
|
||||||
|
|
||||||
public constructor() {
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
this.sitemapXml = fs.readFileSync(
|
this.sitemapXml = fs.readFileSync(
|
||||||
path.join(__dirname, 'assets', 'sitemap.xml'),
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
@ -25,11 +29,51 @@ export class SitemapController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public async flushCache(@Res() response: Response): Promise<void> {
|
public async getSitemapXml(@Res() response: Response): Promise<void> {
|
||||||
|
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||||
|
|
||||||
response.setHeader('content-type', 'application/xml');
|
response.setHeader('content-type', 'application/xml');
|
||||||
response.send(
|
response.send(
|
||||||
interpolate(this.sitemapXml, {
|
interpolate(this.sitemapXml, {
|
||||||
currentDate: format(getYesterday(), DATE_FORMAT)
|
currentDate,
|
||||||
|
personalFinanceTools: this.configurationService.get(
|
||||||
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
|
)
|
||||||
|
? personalFinanceTools
|
||||||
|
.map(({ alias, key }) => {
|
||||||
|
return [
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>',
|
||||||
|
'<url>',
|
||||||
|
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
|
||||||
|
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||||
|
'</url>'
|
||||||
|
].join('\n');
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,4 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -12,14 +6,6 @@ import { SitemapController } from './sitemap.controller';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [SitemapController],
|
controllers: [SitemapController],
|
||||||
imports: [
|
imports: [ConfigurationModule]
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
PrismaModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class SitemapModule {}
|
export class SitemapModule {}
|
||||||
|
@ -22,7 +22,7 @@ export class SubscriptionService {
|
|||||||
this.stripe = new Stripe(
|
this.stripe = new Stripe(
|
||||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||||
{
|
{
|
||||||
apiVersion: '2022-11-15'
|
apiVersion: '2024-04-10'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service';
|
|||||||
controllers: [SymbolController],
|
controllers: [SymbolController],
|
||||||
exports: [SymbolService],
|
exports: [SymbolService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule
|
PrismaModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
],
|
],
|
||||||
providers: [SymbolService]
|
providers: [SymbolService]
|
||||||
})
|
})
|
||||||
|
@ -74,11 +74,21 @@ export class SymbolService {
|
|||||||
date = new Date(),
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
let historicalData: {
|
||||||
[{ dataSource, symbol }],
|
[symbol: string]: {
|
||||||
date,
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
date
|
};
|
||||||
);
|
} = {
|
||||||
|
[symbol]: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||||
|
dataGatheringItems: [{ dataSource, symbol }],
|
||||||
|
from: date,
|
||||||
|
to: date
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketPrice:
|
marketPrice:
|
||||||
|
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteOwnUserDto {
|
||||||
|
@IsString()
|
||||||
|
accessToken: string;
|
||||||
|
}
|
@ -1,26 +1,28 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
import type {
|
import type {
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
HoldingsViewMode,
|
||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { eachYearOfInterval, format } from 'date-fns';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
annualInterestRate?: number;
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
|
|
||||||
@ -32,7 +34,20 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
colorScheme?: ColorScheme;
|
colorScheme?: ColorScheme;
|
||||||
|
|
||||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd'])
|
@IsIn(<DateRange[]>[
|
||||||
|
'1d',
|
||||||
|
'1y',
|
||||||
|
'5y',
|
||||||
|
'max',
|
||||||
|
'mtd',
|
||||||
|
'wtd',
|
||||||
|
'ytd',
|
||||||
|
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
|
||||||
|
(date) => {
|
||||||
|
return format(date, 'yyyy');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
|
|
||||||
@ -52,6 +67,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
'filters.tags'?: string[];
|
'filters.tags'?: string[];
|
||||||
|
|
||||||
|
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||||
|
@IsOptional()
|
||||||
|
holdingsViewMode?: HoldingsViewMode;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user