Compare commits
567 Commits
44850e0802
...
main
Author | SHA1 | Date | |
---|---|---|---|
325556188f | |||
8b9b1c7f11 | |||
2f9023dadd | |||
a3c4f84822 | |||
f501c7aeae | |||
aca2fe1654 | |||
dcf40367c1 | |||
dcedaa4d59 | |||
28b11b979d | |||
7a97ec75f4 | |||
91a6d14e24 | |||
d9fb159c6a | |||
7b88f7978f | |||
bd43ea9b6c | |||
655601c6a3 | |||
e28ab59673 | |||
c8291578b8 | |||
57abe53daf | |||
0b26f2a624 | |||
e8c1185812 | |||
c197622880 | |||
3dc287cfd7 | |||
9b4730b731 | |||
c437bc2534 | |||
433f1686e3 | |||
842a564bb5 | |||
559cac31bd | |||
77d3121f0d | |||
f936c69a7f | |||
3fcd611e29 | |||
4983b7ca12 | |||
a45ad41459 | |||
854a08c7a5 | |||
1ec81d352b | |||
5dfe9b54fe | |||
b34ef31812 | |||
bd04b4a60a | |||
3de2c9a81c | |||
351fd8a1d4 | |||
a919f3bb3b | |||
36741dedf0 | |||
f0eb0dc7fc | |||
ded0385da7 | |||
0fca29199d | |||
2fa16480a5 | |||
c40993ce33 | |||
a7a33eeb56 | |||
a0c5958f81 | |||
d609b75254 | |||
197aea1bec | |||
bad3f87957 | |||
84c4d79a74 | |||
960b871aa5 | |||
24f1aeb4c6 | |||
0334eabdad | |||
d1351b4665 | |||
99cab25fe0 | |||
738f7490f6 | |||
b6854f30e1 | |||
be3a9b8e83 | |||
f33d902560 | |||
7619772bf6 | |||
9bd7f795ac | |||
4964a8675b | |||
39fbb3a27c | |||
3c427a0005 | |||
f5aae11a4c | |||
69371bd42f | |||
38ae224ee1 | |||
ee7bb911d7 | |||
dca821e75c | |||
56128d8fe8 | |||
e45b1e9b1f | |||
c8f46f64fb | |||
1f50a9bf7b | |||
314b121584 | |||
ff447b6de6 | |||
29ecfc1137 | |||
029504d843 | |||
d5d74eb4db | |||
14b213c571 | |||
463dc3b295 | |||
28d9bb81be | |||
26cb9ca11a | |||
3aae0aa40c | |||
f541832666 | |||
d858aa53ec | |||
f351212600 | |||
7a251ef749 | |||
0b143088a2 | |||
6e3cf1460e | |||
46aa2ebc7a | |||
d8626ffab7 | |||
a31f71d7ee | |||
b92eff5b72 | |||
6f4e0f11cf | |||
a56016ee03 | |||
33ef272243 | |||
78d8a1c8d4 | |||
b0e1065f79 | |||
153b162927 | |||
32919f96a9 | |||
5fde161eed | |||
18f94a1ba1 | |||
a7319844fd | |||
8ae91961d9 | |||
0cdad51226 | |||
b63a57cf9e | |||
ec5b2693c0 | |||
da1a6445b1 | |||
ed649fe37e | |||
3ea72af5a0 | |||
ab00ebee1e | |||
ca8525a90c | |||
ffd6f51289 | |||
4b556dfdc4 | |||
c4cde4eef6 | |||
072d700b82 | |||
908a731989 | |||
60c9b5300d | |||
74b51fe1dd | |||
1400660664 | |||
38756b853d | |||
853a3b12ad | |||
c67e351ccc | |||
1674944596 | |||
c453a8cd07 | |||
2f7425d0a2 | |||
8d4a21c096 | |||
fb3f7517e2 | |||
279757fab3 | |||
b0cf5f7f80 | |||
be3be20604 | |||
5af53eb97e | |||
8cedd91561 | |||
9c4fe5fa6b | |||
9c27c1772f | |||
d84e28e058 | |||
54249c708b | |||
66ef2c2fec | |||
72a3b8709b | |||
3b54fcac60 | |||
ac4536abb5 | |||
1a72ef40c2 | |||
b80c186c97 | |||
20070d76bc | |||
17ef147da7 | |||
076ac1a32f | |||
4e985e4796 | |||
c6f25e0fb6 | |||
f3a598a58e | |||
8d3e12c646 | |||
a541ccee1b | |||
78e81ec36a | |||
ef64292cd6 | |||
0f537adf3e | |||
8bc6b51405 | |||
8e3480c0ce | |||
aba42307da | |||
4993d81c80 | |||
ced7f1f206 | |||
d44eba9617 | |||
f9aea95a73 | |||
6056cf9a6a | |||
d77295f64c | |||
376b1416bb | |||
2e377044ed | |||
0d3a99dd39 | |||
c604268507 | |||
3af558c580 | |||
85aa323e84 | |||
c42ffcb2a1 | |||
fec223070f | |||
766d792b10 | |||
a40c726843 | |||
ffce1c7208 | |||
7a0b82b5f0 | |||
fa99876dca | |||
25a755d1bf | |||
71d56ad789 | |||
edba53be29 | |||
9b233da4bf | |||
3bcb19af14 | |||
21baabc7f2 | |||
66e430ab9a | |||
042112faa6 | |||
b3ec353074 | |||
19b8300a4d | |||
1d29005736 | |||
e38ce2852c | |||
31f5c0de88 | |||
840805f9df | |||
e8f185e484 | |||
1dd3857618 | |||
c318e0019e | |||
b5abdaae07 | |||
f87dc437fd | |||
e2ec635bbf | |||
1f01668a52 | |||
7d2bb2116f | |||
fde8ff4bb6 | |||
8cee03f16a | |||
5fdd27eca0 | |||
85af3db0d8 | |||
ccee5b1337 | |||
9366aea9d5 | |||
5c617f761a | |||
de56a35197 | |||
3efe09d3eb | |||
a11fef5e23 | |||
15deed3032 | |||
8b8d194383 | |||
c59d2701f0 | |||
90f2e8101c | |||
7342e7e5ec | |||
ef8bbf1ae8 | |||
9757df859f | |||
32baf1946c | |||
b66e60e9f4 | |||
cb7326edb3 | |||
cb7434a8b2 | |||
cf76f54c1c | |||
fda9cc71f7 | |||
c553fdf6d4 | |||
03b04ac7f0 | |||
c6e38cd4ac | |||
daedfd6ad6 | |||
3bfbb31191 | |||
824985b464 | |||
62e304490d | |||
5fa97a9a40 | |||
5db5fd903e | |||
9e74eec04d | |||
9f3079716b | |||
bd2a8e2444 | |||
a8e48be9da | |||
bbb55dbec9 | |||
0ab7b98077 | |||
316d118111 | |||
6c1273dc8a | |||
a594b58d61 | |||
59abe473f0 | |||
8c2f8033d7 | |||
7e1f4fd11f | |||
9122b674ca | |||
f20791d5ad | |||
502f1c9244 | |||
1f2d2f8d8a | |||
ffe1021d69 | |||
afb35f7036 | |||
13b544c67d | |||
b8d356a949 | |||
692147f629 | |||
9a75e7d257 | |||
d96a346b70 | |||
1a247d6e97 | |||
90385157d7 | |||
a8a31c141d | |||
a1786c95a1 | |||
6bdf7b185f | |||
b5bd2bd997 | |||
71b8121b48 | |||
df11615b2f | |||
7c378d88af | |||
4bffb3107d | |||
8f3fecc5aa | |||
ba6503636e | |||
6e67520b68 | |||
0ea588315a | |||
c33aa82bd7 | |||
3d94b1a873 | |||
11ca51024a | |||
bd2d05b143 | |||
d4666f778d | |||
243ef2206c | |||
1cfc691a3e | |||
fe5d6f702b | |||
24160366b9 | |||
eed9d157f0 | |||
a06872b657 | |||
f4ef91e3be | |||
f361ecc732 | |||
294a1834b6 | |||
f63ede46b0 | |||
3da83fc42b | |||
70a4697f54 | |||
d395b195ff | |||
c2c628e77c | |||
a0f377e8eb | |||
85d07c27b6 | |||
57c43e5815 | |||
273ec92316 | |||
dcc016633e | |||
af79888cd6 | |||
2a934a75ec | |||
698d71fb3a | |||
e7bfcabac2 | |||
ccbf958aa6 | |||
8abc039fac | |||
1697b7e1e0 | |||
8358928aaf | |||
895a6214c7 | |||
e97757631b | |||
722da69987 | |||
a8937fbf04 | |||
e045811232 | |||
365318e6e0 | |||
66eb7adbdb | |||
6c322522d9 | |||
0b7fc7a3b2 | |||
4adc9dc9b1 | |||
7aab1c08b0 | |||
7bf87352c9 | |||
2932d57c1e | |||
3fe5a762eb | |||
12ebce03af | |||
755d85a54b | |||
d99217a434 | |||
480709c32a | |||
b6e8431c53 | |||
11629ffd26 | |||
037d3b1a60 | |||
7af153a161 | |||
72ccf47526 | |||
c81d3ced75 | |||
1215803a40 | |||
92fbf33032 | |||
aadd9f56a0 | |||
4237ded93c | |||
8e76bd82eb | |||
c393aeeb9a | |||
828bd5f172 | |||
edbcd0e225 | |||
03e27dd233 | |||
096699d83b | |||
19eff6e814 | |||
67db1b0de4 | |||
c38dab5ab0 | |||
40d3eaa023 | |||
3ec2460bfe | |||
aaf507b276 | |||
307621e103 | |||
4594f7339b | |||
6e9622a2cf | |||
28d2fd3877 | |||
5b6447b60d | |||
d661cdc78f | |||
ecffb53f07 | |||
b93671c740 | |||
620ae023d9 | |||
308dfaa58d | |||
e4073608e5 | |||
3646fb7f77 | |||
1bced96460 | |||
3e963228d6 | |||
c3887e2f8e | |||
72188374ca | |||
f70d71d5bd | |||
770b322137 | |||
6bb85c4fb8 | |||
e314efb2e1 | |||
575615b972 | |||
2306bc597b | |||
ca992db14e | |||
8fbdcac66c | |||
8df9667979 | |||
73f009e43b | |||
d9d8eadbd3 | |||
afac9daeab | |||
d919622932 | |||
a5fe259761 | |||
db7d45ecb9 | |||
82cf4afd7d | |||
b90bfc3d6e | |||
1b5a65d391 | |||
34f191ef7a | |||
fe1df8095a | |||
7d0af34034 | |||
398833a0e3 | |||
c671ea4022 | |||
909d56ab10 | |||
456327d199 | |||
f209519d95 | |||
c34996fdd6 | |||
a5e13d30ae | |||
b2dcb1e7ac | |||
2774dd7b2e | |||
862de91e7b | |||
07fa345457 | |||
68fb5c8b66 | |||
d2452791cc | |||
e86801dfe9 | |||
7fb0f9b6e8 | |||
28dd26be97 | |||
3b59d7989a | |||
3023d79311 | |||
447fe1806f | |||
9f5cc6a4cb | |||
4c63e08e3c | |||
8dcf04019d | |||
ac37974fd6 | |||
50e7e3d3c7 | |||
10580e22d1 | |||
53a81b3c2b | |||
56fcafaa12 | |||
dfa940c1b4 | |||
d9acd3ace9 | |||
e67ccea673 | |||
fa2739bbb4 | |||
416fa44cf0 | |||
b77afed38c | |||
ad29221e79 | |||
71fc3906c7 | |||
26b705cfea | |||
3091c3f080 | |||
d6e0b499d9 | |||
1ae5ba7f8a | |||
e8e499be26 | |||
de6c416639 | |||
a41032d0af | |||
b287257a29 | |||
6b2966b7dc | |||
c16352b76c | |||
255c3ec5f4 | |||
e498746c4c | |||
8163966212 | |||
1b45ce8619 | |||
f3022ca1f4 | |||
b6f87e46a7 | |||
f29f201a4f | |||
7cebfbc9c2 | |||
db38806812 | |||
13643f578a | |||
edf03d1cd6 | |||
3361666f63 | |||
53122b09ab | |||
073e60055d | |||
07ad7f2817 | |||
5e64de6650 | |||
d23dfadbd0 | |||
c6083ec7c9 | |||
535e9b654d | |||
94e53c7d4a | |||
5072ba09aa | |||
6122da3f14 | |||
cca1637aec | |||
f34fba97ba | |||
55791303a0 | |||
c86033a0b5 | |||
48a0a28d23 | |||
42734dff75 | |||
95325aad14 | |||
b2634db99f | |||
1856d40ad0 | |||
267dfc572a | |||
d1a4cb5037 | |||
e6b7073195 | |||
bb4e034300 | |||
6defc02c28 | |||
38e8284aef | |||
0f557f431e | |||
587c735288 | |||
2c07b7f058 | |||
2deca58928 | |||
ed07610602 | |||
ad47bedcb5 | |||
830720620f | |||
3ec0fe81b4 | |||
f7f057f2e8 | |||
57748a18ef | |||
d3ecbc0a96 | |||
b6fc5566d4 | |||
5754f01819 | |||
6ae22d13ec | |||
f1d292330a | |||
2894274b92 | |||
29739b27ee | |||
a216ba98b6 | |||
4df575e34f | |||
9c7778983d | |||
476b287ef3 | |||
c58b4c1cda | |||
1202d15522 | |||
8cd7679760 | |||
91394160b9 | |||
64cbd276ce | |||
b720a8dd96 | |||
ff563ddfea | |||
e46c25aaeb | |||
70d56c6c6c | |||
a09d3e5beb | |||
2f8767578c | |||
a13d6140cf | |||
31be526c11 | |||
f28c452be0 | |||
f08ed2dc68 | |||
c9e8eb401a | |||
f22c5ee16b | |||
44f6efd427 | |||
8920b60a72 | |||
7183db17b0 | |||
c467c946d4 | |||
a6952a0e37 | |||
5247cc3c97 | |||
4842c347a9 | |||
6c624fefc9 | |||
5ecdb5fb7a | |||
a9c3224856 | |||
b5db651ec8 | |||
198f73db00 | |||
536b000ff9 | |||
150d97bd42 | |||
d8b305a343 | |||
1aa1960d45 | |||
22cde840ea | |||
795e4582a8 | |||
ddc7989280 | |||
82fe2590bf | |||
ee361bf669 | |||
efc0b1bf5a | |||
235db72ade | |||
170b10dbde | |||
4db8c007f0 | |||
51d55f74e9 | |||
6036547cf5 | |||
f17a95eb48 | |||
6cd79fab31 | |||
5783497a66 | |||
29b8d63951 | |||
3efe06409e | |||
0cc3674780 | |||
755ab15755 | |||
b90e3076d0 | |||
6319661b0c | |||
2f868b8902 | |||
1917c17cf9 | |||
9e44023f86 | |||
d8dc02fc4a | |||
a4c78739bb | |||
7535f3f2b4 | |||
f528c45c68 | |||
9a35f1a611 | |||
97037e9481 | |||
1eaea2618a | |||
a137bbbdca | |||
1cb33a407d | |||
9abc4af203 | |||
70cd1a89b5 | |||
27f5536f23 | |||
feea2ced82 | |||
4368beebff | |||
7ca0720244 | |||
6bdfd8984f | |||
52081d6741 | |||
cb1f488eb4 | |||
25d0c1c8a0 | |||
443ff55295 | |||
d346f6b9fa | |||
bdde9dd314 | |||
f7185676b0 | |||
9438931849 | |||
cd2b26c1c2 | |||
ff73e7e0ee | |||
9278adc6ba | |||
9db8c5ccef | |||
589eefaa76 | |||
b260c4f450 |
1
.env.dev
1
.env.dev
@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||
NX_ADD_PLUGINS=false
|
||||
|
||||
NX_NATIVE_COMMAND_RUNNER=false
|
||||
|
42
.github/workflows/build-code.yml
vendored
Normal file
42
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: Build code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 22
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check code style
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: npm test
|
||||
|
||||
- name: Build application
|
||||
run: npm run build:production
|
1
.github/workflows/docker-image.yml
vendored
1
.github/workflows/docker-image.yml
vendored
@ -67,3 +67,4 @@ jobs:
|
||||
webhook_auth: ${{ secrets.WEBHOOK_AUTH }}
|
||||
webhook_secret: ${{ secrets.WEBHOOK_SECRET }}
|
||||
webhook_auth_type: bearer
|
||||
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -27,8 +27,10 @@ npm-debug.log
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.env
|
||||
.env.prod
|
||||
.github/instructions/nx.instructions.md
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
/.sass-cache
|
||||
|
604
CHANGELOG.md
604
CHANGELOG.md
@ -5,6 +5,598 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Made the `getByKey()` function generic in the property service
|
||||
|
||||
## 2.176.0 - 2025-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for generating a new _Security Token_ via the user’s account access panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the main content of the holding detail dialog to a new overview tab
|
||||
- Introduced fuzzy search for the holdings of the assistant
|
||||
- Introduced fuzzy search for the quick links of the assistant
|
||||
- Improved the search results of the assistant to only display categories with content
|
||||
- Enhanced the sitemap to dynamically compose public routes
|
||||
- Renamed `Account` to `account` in the `Order` database schema
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prettier` from version `3.5.3` to `3.6.2`
|
||||
|
||||
## 2.175.0 - 2025-06-28
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Equity)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Fixed Income)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment: Base Currency)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the selector handling of the scraper configuration for more use cases
|
||||
- Extended the _AI_ service by an access to _OpenRouter_ (experimental)
|
||||
- Changed `node main` to `exec node main` in the `entrypoint.sh` file to improve the container signal handling
|
||||
- Renamed `Account` to `account` in the `AccountBalance` database schema
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the locale in the scraper configuration
|
||||
|
||||
## 2.174.0 - 2025-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Current Investment)
|
||||
- Extended the data providers management of the admin control panel by the online status
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Renamed `Platform` to `platform` in the `Account` database schema
|
||||
- Refactored the health check endpoint for data enhancers
|
||||
- Refactored the health check endpoint for data providers
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
## 2.173.0 - 2025-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- Set up `open-color` for CSS variable usage
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the data providers management of the admin control panel
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Renamed `GranteeUser` to `granteeUser` in the `Access` database schema
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `class-validator` from version `0.14.1` to `0.14.2`
|
||||
- Upgraded `prisma` from version `6.9.0` to `6.10.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the `HtmlTemplateMiddleware` related to incorrect variable resolution
|
||||
- Eliminated the _Unsupported route path_ warning of the `LegacyRouteConverter` on startup
|
||||
|
||||
## 2.172.0 - 2025-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Single Account)
|
||||
- Included the admin control panel in the quick links of the assistant
|
||||
|
||||
### Changed
|
||||
|
||||
- Adapted the options of the date range selector in the assistant dynamically based on the user’s first activity
|
||||
- Switched the data provider service to `OnModuleInit`, ensuring (currency) quotes are fetched only once
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Improved the language localization for Chinese (`zh`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
|
||||
## 2.171.0 - 2025-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added the current holdings as default options of the symbol search in the create or update activity dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the assistant
|
||||
- Reused the value component in the data providers management of the admin control panel
|
||||
- Set the market state of exchange rate symbols to `open` in the _Financial Modeling Prep_ service
|
||||
- Restructured the content of the pricing page
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Migrated the `HtmlTemplateMiddleware` to use `@Injectable()`
|
||||
- Renamed `User` to `user` in the database schema
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a date offset issue with account balances
|
||||
- Fixed missing `/.well-known/assetlinks.json` for TWA
|
||||
|
||||
## 2.170.0 - 2025-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- Included quick links in the search results of the assistant
|
||||
- Added a skeleton loader to the changelog page
|
||||
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `ApiKey` to `apiKeys` in the `User` database schema
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.0`
|
||||
- Upgraded `prisma` from version `6.8.2` to `6.9.0`
|
||||
- Upgraded `zone.js` from version `0.15.0` to `0.15.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restricted the date range change permission in the _Zen Mode_
|
||||
|
||||
## 2.169.0 - 2025-06-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the asset profile icon component to entity logo component and moved to `@ghostfolio/ui`
|
||||
- Renamed `Account` to `accounts` in the `User` database schema
|
||||
- Improved the cache verification in the health check endpoint (experimental)
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handled an exception in the get keys function of the _Redis_ cache service
|
||||
- Fixed missing `/.well-known/assetlinks.json` for TWA
|
||||
|
||||
## 2.168.0 - 2025-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added a background gradient to the sidebar navigation
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `i18n` service to use `@Injectable()`
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `nestjs` from version `11.1.0` to `11.1.3`
|
||||
|
||||
## 2.167.0 - 2025-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for column sorting to the markets overview
|
||||
- Added support for column sorting to the watchlist
|
||||
- Set up the language localization for the static portfolio analysis rule: _Emergency Fund_ (Setup)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Fees_ (Fee Ratio)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the symbol search component by default options
|
||||
- Renamed `Tag` to `tags` in the `User` database schema
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.15.0` to `2.15.1`
|
||||
- Upgraded `Nx` from version `20.8.1` to `21.1.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where the import button was not correctly enabled in the import activities dialog
|
||||
- Fixed an issue with empty account balances in the import activities dialog
|
||||
- Fixed an issue in the annualized performance calculation
|
||||
|
||||
## 2.166.0 - 2025-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to create custom tags in the create or update activity dialog (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the card components
|
||||
- Improved the style of the system message
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Improved the language localization for Ukrainian (`uk`)
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `ngx-stripe` from version `19.0.0` to `19.7.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respected the filter by holding when deleting activities on the portfolio activities page
|
||||
- Respected the filter by holding when exporting activities on the portfolio activities page
|
||||
- Fixed an exception with currencies in the historical market data editor of the admin control panel
|
||||
|
||||
## 2.165.0 - 2025-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _General_ section by the performance calculation method on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the _Live Demo_ setup by syncing activities based on tags
|
||||
- Renamed `orders` to `activities` in the `Tag` database schema
|
||||
- Modularized the cron service
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `big.js` from version `6.2.2` to `7.0.1`
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.14.3` to `2.15.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed the investment value to take the currency effects into account in the holding detail dialog
|
||||
|
||||
## 2.164.0 - 2025-05-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `Node.js` from version `20` to `22` (`Dockerfile`)
|
||||
- Upgraded `yahoo-finance2` from version `3.3.4` to `3.3.5`
|
||||
|
||||
## 2.163.0 - 2025-05-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded `yahoo-finance2` from version `3.3.3` to `3.3.4`
|
||||
|
||||
## 2.162.1 - 2025-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added a hint about delayed market data to the markets overview
|
||||
- Added the asset profile count per data provider to the endpoint `GET api/v1/admin`
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the robustness of the search in the _Yahoo Finance_ service by catching schema validation errors
|
||||
- Improved the symbol lookup results by removing the currency from the name of cryptocurrencies (experimental)
|
||||
- Harmonized the data providers management style of the admin control panel
|
||||
- Extended the data providers management of the admin control panel by the asset profile count
|
||||
- Restricted the permissions of the demo user
|
||||
- Renamed `Order` to `activities` in the `User` database schema
|
||||
- Removed the deprecated endpoint `GET api/v1/admin/market-data/:dataSource/:symbol`
|
||||
- Removed the deprecated endpoint `POST api/v1/admin/market-data/:dataSource/:symbol`
|
||||
- Removed the deprecated endpoint `PUT api/v1/admin/market-data/:dataSource/:symbol/:dateString`
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Chinese (`zh`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `countup.js` from version `2.8.0` to `2.8.2`
|
||||
- Upgraded `nestjs` from version `10.4.15` to `11.0.12`
|
||||
- Upgraded `prisma` from version `6.7.0` to `6.8.2`
|
||||
- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Displayed the button to fetch the current market price only if the activity is not in a custom currency
|
||||
- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard`
|
||||
- Improved the text alignment of the allocations by ETF holding on the allocations page (experimental)
|
||||
|
||||
## 2.161.0 - 2025-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the endpoint to get a holding by the date of the last all time high and the current change to the all time high
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `Order` to `activities` in the `SymbolProfile` database schema
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance calculation on the date of an activity when the unit price differs from the market price
|
||||
- Fixed the horizontal overflow in the table of the benchmark component
|
||||
|
||||
## 2.160.0 - 2025-05-04
|
||||
|
||||
### Added
|
||||
|
||||
- Added the watchlist to the features page
|
||||
- Extended the content of the Frequently Asked Questions (FAQ) pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the watchlist from experimental to general availability
|
||||
- Deprecated the endpoint to get a portfolio position in favor of get a holding
|
||||
- Deprecated the endpoint to update portfolio position tags in favor of update holding tags
|
||||
- Renamed `Account` to `accounts` in the `Platform` database schema
|
||||
- Upgraded `prisma` from version `6.6.0` to `6.7.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the fee calculations related to activities in a custom currency
|
||||
|
||||
## 2.159.0 - 2025-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the watchlist by the date of the last all time high, the current change to the all time high and the current market condition (experimental)
|
||||
- Added support for the impersonation mode in the watchlist (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the currency code validation by allowing `GBp`
|
||||
|
||||
## 2.158.0 - 2025-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to delete an asset from the watchlist (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `Order` to `activities` in the `Account` database schema
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the saving of activities with type `INTEREST`, `ITEM` and `LIABILITY`
|
||||
|
||||
## 2.157.1 - 2025-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced a watchlist to follow assets (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the column label from _Index_ to _Name_ in the benchmark component
|
||||
- Extended the data providers management of the admin control panel
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 2.156.0 - 2025-04-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the error message of the currency code validation
|
||||
- Tightened the currency code validation by requiring uppercase letters
|
||||
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
|
||||
- Upgraded `Nx` from version `20.8.0` to `20.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the investment calculation for activities in a custom currency
|
||||
- Improved the file selector of the activities import functionality to accept case-insensitive file extensions (`.CSV` and `.JSON`)
|
||||
- Fixed the missing localization for "someone" on the public page
|
||||
|
||||
## 2.155.0 - 2025-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added the endpoints (`DELETE`, `GET` and `POST`) for the watchlist
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the data source check in the DTO of the activity creation
|
||||
- Simplified the data source check in the DTO of the asset profile update
|
||||
- Renamed `User` to `user` in the `Subscription` database schema
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the settings dialog to customize the rule thresholds of the _X-ray_ page (experimental)
|
||||
|
||||
## 2.154.0 - 2025-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the benchmark detail dialog by the current market price
|
||||
- Added the performance calculation type to the user settings (experimental)
|
||||
- Added `watchlist` to the `User` database schema as a preparation for watching assets
|
||||
|
||||
### Changed
|
||||
|
||||
- Made the historical market data editor expandable in the admin control panel
|
||||
- Renamed `Subscription` to `subscriptions` in the `User` database schema
|
||||
- Parallelized the requests in the get quotes functionality of the _Financial Modeling Prep_ service
|
||||
- Migrated the lookup functionality by `isin` of the _Financial Modeling Prep_ service to its stable API version
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the word wrap in the menu of the historical market data table in the admin control panel
|
||||
|
||||
## 2.153.0 - 2025-04-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Added support for activities in a custom currency
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `chart.js` from version `4.4.7` to `4.4.9`
|
||||
- Upgraded `uuid` from version `11.0.5` to `11.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the functionality to open an asset profile of a custom currency in the admin control panel
|
||||
- Fixed the asset class parsing in the _Financial Modeling Prep_ service for exchange rates
|
||||
|
||||
## 2.152.1 - 2025-04-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Deactivated asset profiles automatically on delisting in the _Yahoo Finance_ service
|
||||
- Optimized the query of the data range functionality (`getRange()`) in the market data service
|
||||
- Moved the subscription offer from the info to the user service
|
||||
- Upgraded `Nx` from version `20.7.1` to `20.8.0`
|
||||
- Upgraded `prisma` from version `6.5.0` to `6.6.0`
|
||||
- Upgraded `storybook` from version `8.4.7` to `8.6.12`
|
||||
|
||||
## 2.151.0 - 2025-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data gathering status column to the historical market data table of the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the maximum number of symbols per request in the _Financial Modeling Prep_ service
|
||||
- Migrated the get quotes functionality of the _Financial Modeling Prep_ service to its stable API version
|
||||
- Improved the language localization for Enlish (`en`)
|
||||
- Upgraded `eslint` dependencies
|
||||
- Upgraded `Nx` from version `20.6.4` to `20.7.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the link to the pricing page in the premium indicator component
|
||||
|
||||
## 2.150.0 - 2025-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to toggle the data gathering for individual asset profiles in the asset profile details dialog of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the check for duplicates in the preview step of the activities import (allow different comments)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
|
||||
|
||||
## 2.149.0 - 2025-03-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental)
|
||||
- Set up the terms of service for the _Ghostfolio_ SaaS (cloud)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the static portfolio analysis rule: Emergency fund setup by supporting assets
|
||||
- Restricted the historical market data gathering to active asset profiles
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `Nx` from version `20.5.0` to `20.6.4`
|
||||
|
||||
## 2.148.0 - 2025-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added the `isActive` flag to the asset profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ngx-skeleton-loader` from version `9.0.0` to `10.0.0`
|
||||
|
||||
## 2.147.0 - 2025-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
|
||||
- Added support for generating a new _Security Token_ via the users table of the admin control panel
|
||||
- Added an endpoint to localize the `site.webmanifest`
|
||||
- Added the _Storybook_ path to the `sitemap.xml` file
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the export functionality by applying filters on accounts and tags
|
||||
- Improved the symbol validation in the _Yahoo Finance_ service (get asset profiles)
|
||||
- Eliminated `firstOrderDate` from the summary of the portfolio details endpoint in favor of using `dateOfFirstActivity` from the user endpoint
|
||||
- Refactored `lodash.uniq` with `Array.from(new Set(...))`
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the activities import functionality related to the account balances
|
||||
- Changed client-side dates to be sent in UTC format to ensure date consistency
|
||||
- Benchmark endpoint
|
||||
- Exchange rate endpoint
|
||||
|
||||
## 2.146.0 - 2025-03-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the user account registration
|
||||
- Improved the usability of the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
|
||||
- Formatted the name in the _Financial Modeling Prep_ service
|
||||
- Removed the exchange rates from the overview of the admin control panel
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `angular` from version `19.0.5` to `19.2.1`
|
||||
- Upgraded `Nx` from version `20.3.2` to `20.5.0`
|
||||
- Upgraded `prettier` from version `3.5.1` to `3.5.3`
|
||||
- Upgraded `prisma` from version `6.4.1` to `6.5.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with serving _Storybook_ related to the `contentSecurityPolicy`
|
||||
|
||||
## 2.145.1 - 2025-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the export functionality by the account balances
|
||||
- Added a _Copy portfolio data to clipboard for AI prompt_ action to the analysis page (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the summary on the _X-ray_ page
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `9.0` to `13.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue to get dividends in the _Financial Modeling Prep_ service
|
||||
- Fixed an issue to get historical market data in the _Financial Modeling Prep_ service
|
||||
- Fixed an issue with serving _Storybook_
|
||||
|
||||
## 2.144.0 - 2025-03-06
|
||||
|
||||
### Fixed
|
||||
@ -3367,7 +3959,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added the language localization for Français (`fr`)
|
||||
- Added the language localization for French (`fr`)
|
||||
- Extended the landing page by a global heat map of subscribers
|
||||
- Added support for the thousand separator in the global heat map component
|
||||
|
||||
@ -3396,7 +3988,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added support for the dividend timeline grouped by year
|
||||
- Added support for the investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for French (`fr`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -3505,7 +4097,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the value redaction interceptor (including `comment`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
|
||||
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
|
||||
|
||||
@ -3734,7 +4326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the usage of the value component in the admin control panel
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -3756,7 +4348,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Español (`es`)
|
||||
- Set up the language localization for Spanish (`es`)
|
||||
- Added support for sectors in mutual funds
|
||||
|
||||
## 1.198.0 - 25.09.2022
|
||||
@ -5539,7 +6131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Changed the navigation to always show the portfolio page
|
||||
- Migrated the data type of currencies from `enum` to `string` in the database
|
||||
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
|
||||
- Respected the accounts' currencies in the exchange rate service
|
||||
- Respected the accounts’ currencies in the exchange rate service
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 22+)
|
||||
- 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`)
|
||||
|
||||
@ -30,7 +30,13 @@ Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
#### English (Default)
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser.
|
||||
|
||||
#### Other Languages
|
||||
|
||||
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
@ -60,6 +66,10 @@ Remove permission in `UserService` using `without()`
|
||||
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Component Library (_Storybook_)
|
||||
|
||||
https://ghostfol.io/development/storybook
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
@ -101,3 +111,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
||||
## SSL
|
||||
|
||||
Generate `localhost.cert` and `localhost.pem` files.
|
||||
|
||||
```
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
|
||||
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
|
||||
```
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
|
||||
COPY ./replace.build.mjs replace.build.mjs
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# 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
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
**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) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing) [](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
|
@ -37,20 +37,20 @@ export class AccessController {
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
({ alias, granteeUser, id, permissions }) => {
|
||||
if (granteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
grantee: granteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
@ -85,11 +85,11 @@ export class AccessController {
|
||||
try {
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
granteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
|
@ -13,7 +13,7 @@ export class AccessService {
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
|
@ -30,7 +30,7 @@ export class AccountBalanceService {
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
@ -46,7 +46,7 @@ export class AccountBalanceService {
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
@ -154,7 +154,7 @@ export class AccountBalanceService {
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = { isExcluded: false };
|
||||
where.account = { isExcluded: false };
|
||||
}
|
||||
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
@ -163,7 +163,7 @@ export class AccountBalanceService {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
Account: true,
|
||||
account: true,
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
@ -174,10 +174,10 @@ export class AccountBalanceService {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
accountId: balance.Account.id,
|
||||
accountId: balance.account.id,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
balance.account.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
AccountsResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -57,17 +57,17 @@ export class AccountController {
|
||||
@HasPermission(permissions.deleteAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
const account = await this.accountService.accountWithOrders(
|
||||
const account = await this.accountService.accountWithActivities(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
{ Order: true }
|
||||
{ activities: true }
|
||||
);
|
||||
|
||||
if (!account || account?.Order.length > 0) {
|
||||
if (!account || account?.activities.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -87,10 +87,10 @@ export class AccountController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string
|
||||
): Promise<Accounts> {
|
||||
): Promise<AccountsResponse> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
@ -110,7 +110,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
@ -152,8 +152,8 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -163,7 +163,7 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -250,8 +250,8 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
@ -270,10 +270,10 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: originalAccount.platformId
|
||||
platform: originalAccount.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
|
@ -7,7 +7,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import {
|
||||
Account,
|
||||
AccountBalance,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
@ -33,12 +39,12 @@ export class AccountService {
|
||||
return account;
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
public async accountWithActivities(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
|
||||
accountInclude: Prisma.AccountInclude
|
||||
): Promise<
|
||||
Account & {
|
||||
Order?: Order[];
|
||||
activities?: Order[];
|
||||
}
|
||||
> {
|
||||
return this.prismaService.account.findUnique({
|
||||
@ -56,13 +62,19 @@ export class AccountService {
|
||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
activities?: Order[];
|
||||
balances?: AccountBalance[];
|
||||
platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||
const isBalancesIncluded = !!include.balances;
|
||||
|
||||
include.balances = {
|
||||
orderBy: { date: 'desc' },
|
||||
...(isBalancesIncluded ? {} : { take: 1 })
|
||||
};
|
||||
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
cursor,
|
||||
@ -76,7 +88,9 @@ export class AccountService {
|
||||
return accounts.map((account) => {
|
||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||
|
||||
delete account.balances;
|
||||
if (!isBalancesIncluded) {
|
||||
delete account.balances;
|
||||
}
|
||||
|
||||
return account;
|
||||
});
|
||||
@ -126,7 +140,10 @@ export class AccountService {
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
const accounts = await this.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
include: {
|
||||
activities: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
@ -134,15 +151,15 @@ export class AccountService {
|
||||
return accounts.map((account) => {
|
||||
let transactionCount = 0;
|
||||
|
||||
for (const order of account.Order) {
|
||||
if (!order.isDraft) {
|
||||
for (const { isDraft } of account.activities) {
|
||||
if (!isDraft) {
|
||||
transactionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...account, transactionCount };
|
||||
|
||||
delete result.Order;
|
||||
delete result.activities;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
@ -3,22 +3,22 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
|
||||
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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminUsers,
|
||||
EnhancedSymbolProfile
|
||||
EnhancedSymbolProfile,
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -50,8 +50,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
@ -59,8 +57,8 @@ export class AdminController {
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly demoService: DemoService,
|
||||
private readonly manualService: ManualService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -68,7 +66,14 @@ export class AdminController {
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
return this.adminService.get();
|
||||
return this.adminService.get({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('demo-user/sync')
|
||||
@HasPermission(permissions.syncDemoUserAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
|
||||
return this.demoService.syncDemoUserAccount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@ -83,7 +88,7 @@ export class AdminController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
@ -92,9 +97,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
@ -110,7 +115,7 @@ export class AdminController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
@ -119,9 +124,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
@ -142,9 +147,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
}
|
||||
@ -214,30 +219,16 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol/test')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async testMarketData(
|
||||
@Body() data: { scraperConfiguration: string },
|
||||
@Body() data: { scraperConfiguration: ScraperConfiguration },
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<{ price: number }> {
|
||||
try {
|
||||
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
||||
const price = await this.manualService.test(scraperConfiguration);
|
||||
const price = await this.manualService.test(data.scraperConfiguration);
|
||||
|
||||
if (price) {
|
||||
return { price };
|
||||
@ -253,58 +244,6 @@ export class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
|
||||
return this.marketDataService.updateMany({
|
||||
data: dataBulkUpdate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||
where: {
|
||||
dataSource_date_symbol: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -334,15 +273,14 @@ export class AdminController {
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Body() assetProfile: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
return this.adminService.patchAssetProfileData(
|
||||
{ dataSource, symbol },
|
||||
assetProfile
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { DemoModule } from '@ghostfolio/api/services/demo/demo.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
DemoModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
@ -10,7 +9,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
@ -31,9 +29,14 @@ import {
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
Logger
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
@ -44,6 +47,7 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
@ -57,7 +61,6 @@ export class AdminService {
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@ -111,9 +114,8 @@ export class AdminService {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
|
||||
const currency = getCurrencyFromSymbol(symbol);
|
||||
const customCurrencies = (await this.propertyService.getByKey(
|
||||
PROPERTY_CURRENCIES
|
||||
)) as string[];
|
||||
const customCurrencies =
|
||||
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
|
||||
|
||||
if (customCurrencies.includes(currency)) {
|
||||
const updatedCustomCurrencies = customCurrencies.filter(
|
||||
@ -131,31 +133,11 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
const exchangeRates = this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== DEFAULT_CURRENCY;
|
||||
})
|
||||
.map((currency) => {
|
||||
const label1 = DEFAULT_CURRENCY;
|
||||
const label2 = currency;
|
||||
|
||||
return {
|
||||
label1,
|
||||
label2,
|
||||
dataSource:
|
||||
DataSource[
|
||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||
],
|
||||
symbol: `${label1}${label2}`,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
DEFAULT_CURRENCY,
|
||||
currency
|
||||
)
|
||||
};
|
||||
});
|
||||
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
|
||||
const dataSources = await this.dataProviderService.getDataSources({
|
||||
user,
|
||||
includeGhostfolio: true
|
||||
});
|
||||
|
||||
const [settings, transactionCount, userCount] = await Promise.all([
|
||||
this.propertyService.get(),
|
||||
@ -163,8 +145,27 @@ export class AdminService {
|
||||
this.countUsersWithAnalytics()
|
||||
]);
|
||||
|
||||
const dataProviders = await Promise.all(
|
||||
dataSources.map(async (dataSource) => {
|
||||
const dataProviderInfo = this.dataProviderService
|
||||
.getDataProvider(dataSource)
|
||||
.getDataProviderInfo();
|
||||
|
||||
const assetProfileCount = await this.prismaService.symbolProfile.count({
|
||||
where: {
|
||||
dataSource
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...dataProviderInfo,
|
||||
assetProfileCount
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
exchangeRates,
|
||||
dataProviders,
|
||||
settings,
|
||||
transactionCount,
|
||||
userCount,
|
||||
@ -243,7 +244,7 @@ export class AdminService {
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
activities: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
@ -261,7 +262,15 @@ export class AdminService {
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
select: {
|
||||
activities: true,
|
||||
watchedBy: true
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
@ -270,13 +279,9 @@ export class AdminService {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isActive: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true,
|
||||
@ -323,6 +328,7 @@ export class AdminService {
|
||||
assetProfiles.map(
|
||||
async ({
|
||||
_count,
|
||||
activities,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
@ -330,9 +336,9 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isActive,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol,
|
||||
SymbolProfileOverrides
|
||||
@ -388,14 +394,17 @@ export class AdminService {
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
isActive,
|
||||
lastMarketPrice,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
activitiesCount: _count.activities,
|
||||
date: activities?.[0]?.date,
|
||||
isUsedByUsersWithSubscription:
|
||||
await isUsedByUsersWithSubscription,
|
||||
watchedByCount: _count.watchedBy
|
||||
};
|
||||
}
|
||||
)
|
||||
@ -471,7 +480,8 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
symbol,
|
||||
isActive: true
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -491,61 +501,126 @@ export class AdminService {
|
||||
return { count, users };
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
url
|
||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: AssetProfileIdentifier &
|
||||
Prisma.SymbolProfileUpdateInput = {
|
||||
public async patchAssetProfileData(
|
||||
{ dataSource, symbol }: AssetProfileIdentifier,
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
dataSource: newDataSource,
|
||||
holdings,
|
||||
isActive,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbol: newSymbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput
|
||||
) {
|
||||
if (
|
||||
newSymbol &&
|
||||
newDataSource &&
|
||||
(newSymbol !== symbol || newDataSource !== dataSource)
|
||||
) {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
]);
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
if (assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.CONFLICT),
|
||||
StatusCodes.CONFLICT
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
try {
|
||||
Promise.all([
|
||||
await this.symbolProfileService.updateAssetProfileIdentifier(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
),
|
||||
await this.marketDataService.updateAssetProfileIdentifier(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
return this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
])?.[0];
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
isActive,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
updatedSymbolProfile
|
||||
);
|
||||
|
||||
return this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: dataSource as DataSource,
|
||||
symbol: symbol as string
|
||||
}
|
||||
])?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
@ -572,7 +647,7 @@ export class AdminService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -596,10 +671,10 @@ export class AdminService {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
activities: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
user: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
@ -617,7 +692,7 @@ export class AdminService {
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
return _count.activities > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -704,8 +779,10 @@ export class AdminService {
|
||||
countriesCount: 0,
|
||||
date: dateOfFirstActivity,
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
sectorsCount: 0,
|
||||
watchedByCount: 0
|
||||
};
|
||||
}
|
||||
);
|
||||
@ -728,13 +805,13 @@ export class AdminService {
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
analytics: {
|
||||
lastRequestAt: 'desc'
|
||||
}
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -746,9 +823,9 @@ export class AdminService {
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
select: { accounts: true, activities: true }
|
||||
},
|
||||
Analytics: {
|
||||
analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
@ -759,26 +836,33 @@ export class AdminService {
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
subscriptions: {
|
||||
orderBy: {
|
||||
expiresAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
where: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
({ _count, analytics, createdAt, id, role, subscriptions }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
const engagement = analytics
|
||||
? analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription({
|
||||
createdAt,
|
||||
subscriptions: Subscription
|
||||
})
|
||||
: undefined;
|
||||
const subscription =
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
subscriptions?.length > 0
|
||||
? subscriptions[0]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
@ -786,11 +870,11 @@ export class AdminService {
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
accountCount: _count.accounts || 0,
|
||||
activityCount: _count.activities || 0,
|
||||
country: analytics?.country,
|
||||
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: analytics?.updatedAt
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@ -19,8 +20,8 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comment?: string;
|
||||
|
||||
@IsArray()
|
||||
@ -31,8 +32,16 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(DataSource)
|
||||
@IsOptional()
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsObject()
|
||||
@ -43,6 +52,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
sectors?: Prisma.InputJsonArray;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
symbol?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
|
@ -1,20 +1,21 @@
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -32,11 +33,14 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { AiModule } from './endpoints/ai/ai.module';
|
||||
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
|
||||
import { AssetsModule } from './endpoints/assets/assets.module';
|
||||
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
|
||||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
|
||||
import { MarketDataModule } from './endpoints/market-data/market-data.module';
|
||||
import { PublicModule } from './endpoints/public/public.module';
|
||||
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
|
||||
import { TagsModule } from './endpoints/tags/tags.module';
|
||||
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
@ -47,7 +51,6 @@ import { OrderModule } from './order/order.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
@ -61,6 +64,7 @@ import { UserModule } from './user/user.module';
|
||||
AiModule,
|
||||
ApiKeysModule,
|
||||
AssetModule,
|
||||
AssetsModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarksModule,
|
||||
@ -75,6 +79,7 @@ import { UserModule } from './user/user.module';
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ConfigurationModule,
|
||||
CronModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
@ -98,7 +103,7 @@ import { UserModule } from './user/user.module';
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
exclude: ['/api*', '/sitemap.xml'],
|
||||
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
serveStaticOptions: {
|
||||
setHeaders: (res) => {
|
||||
@ -121,13 +126,21 @@ import { UserModule } from './user/user.module';
|
||||
}
|
||||
}
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'client', '.well-known'),
|
||||
serveRoot: '/.well-known'
|
||||
}),
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagsModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
UserModule,
|
||||
WatchlistModule
|
||||
],
|
||||
providers: [CronService]
|
||||
providers: [I18nService]
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
public configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
|
||||
}
|
||||
}
|
||||
|
@ -21,37 +21,31 @@ export class ApiKeyStrategy extends PassportStrategy(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super(
|
||||
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
|
||||
true,
|
||||
async (apiKey: string, done: (error: any, user?: any) => void) => {
|
||||
try {
|
||||
const user = await this.validateApiKey(apiKey);
|
||||
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
public async validate(apiKey: string) {
|
||||
const user = await this.validateApiKey(apiKey);
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
activityCount: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { user: { connect: { id: user.id } } },
|
||||
update: {
|
||||
activityCount: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async validateApiKey(apiKey: string) {
|
||||
|
@ -20,10 +20,10 @@ export class AuthService {
|
||||
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
accessToken,
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
);
|
||||
const hashedAccessToken = this.userService.createAccessToken({
|
||||
password: accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken }
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
HEADER_KEY_TIMEZONE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
create: { country, user: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.baseCurrency) {
|
||||
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.language) {
|
||||
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
verifyRegistrationResponse,
|
||||
VerifyRegistrationResponseOpts
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
|
||||
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
@ -54,10 +55,9 @@ export class WebAuthService {
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userID: isoUint8Array.fromUTF8String(user.id),
|
||||
userName: '',
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
requireResidentKey: false,
|
||||
@ -111,11 +111,17 @@ export class WebAuthService {
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (registrationInfo && verified) {
|
||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||
const {
|
||||
credential: {
|
||||
counter,
|
||||
id: credentialId,
|
||||
publicKey: credentialPublicKey
|
||||
}
|
||||
} = registrationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
);
|
||||
let existingDevice = devices.find((device) => {
|
||||
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
|
||||
});
|
||||
|
||||
if (!existingDevice) {
|
||||
/**
|
||||
@ -123,9 +129,9 @@ export class WebAuthService {
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialId: Buffer.from(credentialID),
|
||||
credentialId: Buffer.from(credentialId),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
User: { connect: { id: user.id } }
|
||||
user: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
@ -148,9 +154,8 @@ export class WebAuthService {
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
transports: ['internal'],
|
||||
type: 'public-key'
|
||||
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||
transports: ['internal']
|
||||
}
|
||||
],
|
||||
rpID: this.rpID,
|
||||
@ -187,10 +192,10 @@ export class WebAuthService {
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
credential: {
|
||||
counter: device.counter,
|
||||
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||
publicKey: device.credentialPublicKey
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
|
@ -1,14 +1,18 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -18,19 +22,35 @@ import { AiService } from './ai.service';
|
||||
export class AiController {
|
||||
public constructor(
|
||||
private readonly aiService: AiService,
|
||||
private readonly apiService: ApiService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get('prompt')
|
||||
@Get('prompt/:mode')
|
||||
@HasPermission(permissions.readAiPrompt)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPrompt(): Promise<AiPromptResponse> {
|
||||
public async getPrompt(
|
||||
@Param('mode') mode: AiPromptMode,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<AiPromptResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const prompt = await this.aiService.getPrompt({
|
||||
filters,
|
||||
mode,
|
||||
impersonationId: undefined,
|
||||
languageCode:
|
||||
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
|
||||
userCurrency:
|
||||
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
|
||||
languageCode: this.request.user.Settings.settings.language,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
|
@ -7,13 +7,17 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
|
||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
@ -25,14 +29,18 @@ import { AiService } from './ai.service';
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
imports: [
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
|
@ -1,29 +1,65 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_API_KEY_OPENROUTER,
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import type { AiPromptMode } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
public constructor(private readonly portfolioService: PortfolioService) {}
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async generateText({ prompt }: { prompt: string }) {
|
||||
const openRouterApiKey = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_API_KEY_OPENROUTER
|
||||
);
|
||||
|
||||
const openRouterModel = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
);
|
||||
|
||||
const openRouterService = createOpenRouter({
|
||||
apiKey: openRouterApiKey
|
||||
});
|
||||
|
||||
return generateText({
|
||||
prompt,
|
||||
model: openRouterService.chat(openRouterModel)
|
||||
});
|
||||
}
|
||||
|
||||
public async getPrompt({
|
||||
filters,
|
||||
impersonationId,
|
||||
languageCode,
|
||||
mode,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
languageCode: string;
|
||||
mode: AiPromptMode;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
filters,
|
||||
impersonationId,
|
||||
userId
|
||||
});
|
||||
|
||||
const holdingsTable = [
|
||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
...Object.values(holdings)
|
||||
.sort((a, b) => {
|
||||
@ -43,6 +79,10 @@ export class AiService {
|
||||
)
|
||||
];
|
||||
|
||||
if (mode === 'portfolio') {
|
||||
return holdingsTable.join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
|
||||
...holdingsTable,
|
||||
|
46
apps/api/src/app/endpoints/assets/assets.controller.ts
Normal file
46
apps/api/src/app/endpoints/assets/assets.controller.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { interpolate } from '@ghostfolio/common/helper';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Res,
|
||||
Version,
|
||||
VERSION_NEUTRAL
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@Controller('assets')
|
||||
export class AssetsController {
|
||||
private webManifest = '';
|
||||
|
||||
public constructor(
|
||||
public readonly configurationService: ConfigurationService
|
||||
) {
|
||||
try {
|
||||
this.webManifest = readFileSync(
|
||||
join(__dirname, 'assets', 'site.webmanifest'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get('/:languageCode/site.webmanifest')
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public getWebManifest(
|
||||
@Param('languageCode') languageCode: string,
|
||||
@Res() response: Response
|
||||
): void {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
const webManifest = interpolate(this.webManifest, {
|
||||
languageCode,
|
||||
rootUrl
|
||||
});
|
||||
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.send(webManifest);
|
||||
}
|
||||
}
|
11
apps/api/src/app/endpoints/assets/assets.module.ts
Normal file
11
apps/api/src/app/endpoints/assets/assets.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AssetsController } from './assets.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetsController],
|
||||
providers: [ConfigurationService]
|
||||
})
|
||||
export class AssetsModule {}
|
@ -15,6 +15,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
|
||||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderGhostfolioAssetProfileResponse,
|
||||
DataProviderGhostfolioStatusResponse,
|
||||
DividendsResponse,
|
||||
HistoricalResponse,
|
||||
@ -23,6 +24,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { isISIN } from 'class-validator';
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { GetDividendsDto } from './get-dividends.dto';
|
||||
@ -37,16 +39,12 @@ export class GhostfolioController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('dividends/:symbol')
|
||||
@Get('asset-profile/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getDividendsV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
): Promise<DividendsResponse> {
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
public async getAssetProfile(
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<DataProviderGhostfolioAssetProfileResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
@ -59,18 +57,15 @@ export class GhostfolioController {
|
||||
}
|
||||
|
||||
try {
|
||||
const dividends = await this.ghostfolioService.getDividends({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
const assetProfile = await this.ghostfolioService.getAssetProfile({
|
||||
symbol
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return dividends;
|
||||
return assetProfile;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
@ -119,48 +114,6 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getHistoricalV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
): Promise<HistoricalResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const historicalData = await this.ghostfolioService.getHistorical({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return historicalData;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@ -201,47 +154,6 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async lookupSymbolV1(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<LookupResponse> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ghostfolioService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase()
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@ -265,7 +177,9 @@ export class GhostfolioController {
|
||||
try {
|
||||
const result = await this.ghostfolioService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase()
|
||||
query: isISIN(query.toUpperCase())
|
||||
? query.toUpperCase()
|
||||
: query.toLowerCase()
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
@ -281,44 +195,6 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getQuotesV1(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quotes = await this.ghostfolioService.getQuotes({
|
||||
symbols: query.symbols
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return quotes;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@ -355,16 +231,6 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
|
||||
import {
|
||||
GetAssetProfileParams,
|
||||
GetDividendsParams,
|
||||
GetHistoricalParams,
|
||||
GetQuotesParams,
|
||||
@ -15,6 +17,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DataProviderGhostfolioAssetProfileResponse,
|
||||
DataProviderInfo,
|
||||
DividendsResponse,
|
||||
HistoricalResponse,
|
||||
@ -25,7 +28,7 @@ import {
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@Injectable()
|
||||
@ -37,6 +40,44 @@ export class GhostfolioService {
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async getAssetProfile({
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbol
|
||||
}: GetAssetProfileParams) {
|
||||
let result: DataProviderGhostfolioAssetProfileResponse = {};
|
||||
|
||||
try {
|
||||
const promises: Promise<Partial<SymbolProfile>>[] = [];
|
||||
|
||||
for (const dataProviderService of this.getDataProviderServices()) {
|
||||
promises.push(
|
||||
dataProviderService
|
||||
.getAssetProfile({
|
||||
requestTimeout,
|
||||
symbol
|
||||
})
|
||||
.then((assetProfile) => {
|
||||
result = {
|
||||
...result,
|
||||
...assetProfile,
|
||||
dataSource: DataSource.GHOSTFOLIO
|
||||
};
|
||||
|
||||
return assetProfile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity,
|
||||
@ -123,9 +164,9 @@ export class GhostfolioService {
|
||||
|
||||
public async getMaxDailyRequests() {
|
||||
return parseInt(
|
||||
((await this.propertyService.getByKey(
|
||||
(await this.propertyService.getByKey<string>(
|
||||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
||||
)) as string) || '0',
|
||||
)) || '0',
|
||||
10
|
||||
);
|
||||
}
|
||||
@ -277,6 +318,7 @@ export class GhostfolioService {
|
||||
});
|
||||
|
||||
results.items = filteredItems;
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
@ -286,10 +328,15 @@ export class GhostfolioService {
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
|
||||
this.configurationService,
|
||||
this.propertyService
|
||||
);
|
||||
|
||||
return {
|
||||
...ghostfolioDataProviderService.getDataProviderInfo(),
|
||||
isPremium: false,
|
||||
name: 'Ghostfolio Premium',
|
||||
url: 'https://ghostfol.io'
|
||||
name: 'Ghostfolio Premium'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -85,7 +85,7 @@ export class MarketDataController {
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
@ -103,7 +103,7 @@ export class MarketDataController {
|
||||
);
|
||||
|
||||
const canUpsertOwnAssetProfile =
|
||||
assetProfile.userId === this.request.user.id &&
|
||||
assetProfile?.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketDataOfOwnAssetProfile
|
||||
|
@ -9,8 +9,10 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -25,8 +27,10 @@ import { PublicController } from './public.controller';
|
||||
controllers: [PublicController],
|
||||
imports: [
|
||||
AccessModule,
|
||||
BenchmarkModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly sitemapService: SitemapService
|
||||
) {
|
||||
try {
|
||||
this.sitemapXml = readFileSync(
|
||||
join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public getSitemapXml(@Res() response: Response) {
|
||||
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
personalFinanceTools: this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.sitemapService.getPersonalFinanceTools({ currentDate })
|
||||
: '',
|
||||
publicRoutes: this.sitemapService.getPublicRoutes({
|
||||
currentDate
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SitemapController],
|
||||
imports: [ConfigurationModule]
|
||||
imports: [ConfigurationModule, I18nModule],
|
||||
providers: [SitemapService]
|
||||
})
|
||||
export class SitemapModule {}
|
116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
|
||||
import { publicRoutes } from '@ghostfolio/common/routes/routes';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
|
||||
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly i18nService: I18nService
|
||||
) {}
|
||||
|
||||
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
|
||||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
|
||||
return personalFinanceTools.map(({ alias, key }) => {
|
||||
const route =
|
||||
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
|
||||
.product;
|
||||
const params = {
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl,
|
||||
urlPostfix: alias ?? key
|
||||
};
|
||||
|
||||
return this.createRouteSitemapUrl({ ...params, route });
|
||||
});
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
public getPublicRoutes({ currentDate }: { currentDate: string }) {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
|
||||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
|
||||
const params = {
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl
|
||||
};
|
||||
|
||||
return [
|
||||
this.createRouteSitemapUrl(params),
|
||||
...this.createSitemapUrls(params, publicRoutes)
|
||||
];
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
private createRouteSitemapUrl({
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl,
|
||||
route,
|
||||
urlPostfix
|
||||
}: {
|
||||
currentDate: string;
|
||||
languageCode: string;
|
||||
rootUrl: string;
|
||||
route?: PublicRoute;
|
||||
urlPostfix?: string;
|
||||
}): string {
|
||||
const segments =
|
||||
route?.routerLink.map((link) => {
|
||||
const match = link.match(
|
||||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
|
||||
);
|
||||
|
||||
const segment = match
|
||||
? (this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: match.groups.id
|
||||
}) ?? match.groups.message)
|
||||
: link;
|
||||
|
||||
return segment.replace(/^\/+|\/+$/, '');
|
||||
}) ?? [];
|
||||
|
||||
const location =
|
||||
[rootUrl, languageCode, ...segments].join('/') +
|
||||
(urlPostfix ? `-${urlPostfix}` : '');
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${location}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
' </url>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private createSitemapUrls(
|
||||
params: { currentDate: string; languageCode: string; rootUrl: string },
|
||||
routes: Record<string, PublicRoute>
|
||||
): string[] {
|
||||
return Object.values(routes).flatMap((route) => {
|
||||
if (route.excludeFromSitemap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urls = [this.createRouteSitemapUrl({ ...params, route })];
|
||||
|
||||
if (route.subRoutes) {
|
||||
urls.push(...this.createSitemapUrls(params, route.subRoutes));
|
||||
}
|
||||
|
||||
return urls;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { IsEnum, IsString } from 'class-validator';
|
||||
|
||||
export class CreateWatchlistItemDto {
|
||||
@IsEnum(DataSource)
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
}
|
100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
|
||||
@Controller('watchlist')
|
||||
export class WatchlistController {
|
||||
public constructor(
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly watchlistService: WatchlistService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HasPermission(permissions.createWatchlistItem)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
|
||||
return this.watchlistService.createWatchlistItem({
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@HasPermission(permissions.deleteWatchlistItem)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async deleteWatchlistItem(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const watchlistItems = await this.watchlistService.getWatchlistItems(
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const watchlistItem = watchlistItems.find((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
});
|
||||
|
||||
if (!watchlistItem) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return this.watchlistService.deleteWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HasPermission(permissions.readWatchlist)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getWatchlistItems(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<WatchlistResponse> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const watchlist = await this.watchlistService.getWatchlistItems(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
return {
|
||||
watchlist
|
||||
};
|
||||
}
|
||||
}
|
31
apps/api/src/app/endpoints/watchlist/watchlist.module.ts
Normal file
31
apps/api/src/app/endpoints/watchlist/watchlist.module.ts
Normal file
@ -0,0 +1,31 @@
|
||||
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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WatchlistController } from './watchlist.controller';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
|
||||
@Module({
|
||||
controllers: [WatchlistController],
|
||||
imports: [
|
||||
BenchmarkModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [WatchlistService]
|
||||
})
|
||||
export class WatchlistModule {}
|
150
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
150
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class WatchlistService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async createWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
|
||||
where: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
});
|
||||
|
||||
if (!symbolProfile) {
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
await this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
await this.prismaService.user.update({
|
||||
data: {
|
||||
watchlist: {
|
||||
connect: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.user.update({
|
||||
data: {
|
||||
watchlist: {
|
||||
disconnect: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
}
|
||||
|
||||
public async getWatchlistItems(
|
||||
userId: string
|
||||
): Promise<WatchlistResponse['watchlist']> {
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
select: {
|
||||
watchlist: {
|
||||
select: { dataSource: true, symbol: true }
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
const [assetProfiles, quotes] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles(user.watchlist),
|
||||
this.dataProviderService.getQuotes({
|
||||
items: user.watchlist.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
const watchlist = await Promise.all(
|
||||
user.watchlist.map(async ({ dataSource, symbol }) => {
|
||||
const assetProfile = assetProfiles.find((profile) => {
|
||||
return profile.dataSource === dataSource && profile.symbol === symbol;
|
||||
});
|
||||
|
||||
const allTimeHigh = await this.marketDataService.getMax({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
const performancePercent =
|
||||
this.benchmarkService.calculateChangeInPercentage(
|
||||
allTimeHigh?.marketPrice,
|
||||
quotes[symbol]?.marketPrice
|
||||
);
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
marketCondition:
|
||||
this.benchmarkService.getMarketCondition(performancePercent),
|
||||
name: assetProfile?.name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent,
|
||||
date: allTimeHigh?.date
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return watchlist.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,9 +1,17 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
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 { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -19,15 +27,21 @@ export class ExportController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async export(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') activityIds?: string[],
|
||||
@Query('activityIds') filterByActivityIds?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const activityIds = filterByActivityIds?.split(',') ?? [];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, ApiModule, OrderModule, TagModule],
|
||||
controllers: [ExportController],
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
OrderModule,
|
||||
TagModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -28,43 +28,6 @@ export class ExportService {
|
||||
}): Promise<Export> {
|
||||
const platformsMap: { [platformId: string]: Platform } = {};
|
||||
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
include: {
|
||||
Platform: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
).map(
|
||||
({
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
Platform: platform,
|
||||
platformId
|
||||
}) => {
|
||||
if (platformId) {
|
||||
platformsMap[platformId] = platform;
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
@ -75,16 +38,72 @@ export class ExportService {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
activities = activities.filter((activity) => {
|
||||
return activityIds.includes(activity.id);
|
||||
if (activityIds?.length > 0) {
|
||||
activities = activities.filter(({ id }) => {
|
||||
return activityIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
const tags = (await this.tagService.getTagsForUser(userId))
|
||||
.filter(({ isUsed }) => {
|
||||
return isUsed;
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
include: {
|
||||
balances: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
)
|
||||
.filter(({ id }) => {
|
||||
return activities.length > 0
|
||||
? activities.some(({ accountId }) => {
|
||||
return accountId === id;
|
||||
})
|
||||
: true;
|
||||
})
|
||||
.map(
|
||||
({
|
||||
balance,
|
||||
balances,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platform,
|
||||
platformId
|
||||
}) => {
|
||||
if (platformId) {
|
||||
platformsMap[platformId] = platform;
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
balances: balances.map(({ date, value }) => {
|
||||
return { date: date.toISOString(), value };
|
||||
}),
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const tags = (await this.tagService.getTagsForUser(userId))
|
||||
.filter(
|
||||
({ id, isUsed }) =>
|
||||
isUsed &&
|
||||
activities.some((activity) => {
|
||||
return activity.tags.some(({ id: tagId }) => {
|
||||
return tagId === id;
|
||||
});
|
||||
})
|
||||
)
|
||||
.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
@ -101,6 +120,7 @@ export class ExportService {
|
||||
({
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
@ -118,7 +138,7 @@ export class ExportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
currency: SymbolProfile.currency,
|
||||
currency: currency ?? SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
DataEnhancerHealthResponse,
|
||||
DataProviderHealthResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
@ -37,23 +41,30 @@ export class HealthController {
|
||||
}
|
||||
|
||||
@Get('data-enhancer/:name')
|
||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||
public async getHealthOfDataEnhancer(
|
||||
@Param('name') name: string,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataEnhancerHealthResponse>> {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
return response.status(HttpStatus.OK).json({
|
||||
status: getReasonPhrase(StatusCodes.OK)
|
||||
});
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
|
||||
@Get('data-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataProviderHealthResponse>> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
@ -64,11 +75,14 @@ export class HealthController {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
return response
|
||||
.status(HttpStatus.OK)
|
||||
.json({ status: getReasonPhrase(StatusCodes.OK) });
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
apps/api/src/app/import/create-account-with-balances.dto.ts
Normal file
10
apps/api/src/app/import/create-account-with-balances.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { AccountBalance } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { IsArray, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAccountWithBalancesDto extends CreateAccountDto {
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
balances?: AccountBalance[];
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => CreateAccountDto)
|
||||
@Type(() => CreateAccountWithBalancesDto)
|
||||
@ValidateNested({ each: true })
|
||||
accounts: CreateAccountDto[];
|
||||
accounts: CreateAccountWithBalancesDto[];
|
||||
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
|
@ -71,7 +71,7 @@ export class ImportController {
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
accountsWithBalancesDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
user: this.request.user
|
||||
});
|
||||
@ -98,12 +98,10 @@ export class ImportController {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
|
@ -10,12 +10,10 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -29,10 +27,12 @@ import {
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { isNumber, uniqBy } from 'lodash';
|
||||
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { omit, uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
@ -40,7 +40,6 @@ export class ImportService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -50,11 +49,16 @@ export class ImportService {
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
||||
userId
|
||||
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
const { activities, firstBuyDate, historicalData } =
|
||||
await this.portfolioService.getHolding({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId,
|
||||
impersonationId: undefined
|
||||
});
|
||||
|
||||
const [[assetProfile], dividends] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
@ -72,15 +76,15 @@ export class ImportService {
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
const accounts = activities
|
||||
.filter(({ account }) => {
|
||||
return !!account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
.map(({ account }) => {
|
||||
return account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return await Promise.all(
|
||||
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
|
||||
@ -92,9 +96,9 @@ export class ImportService {
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
const isDuplicate = activities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.accountId === account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
@ -110,17 +114,18 @@ export class ImportService {
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
Account,
|
||||
account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountId: account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
currency: undefined,
|
||||
createdAt: undefined,
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
@ -128,15 +133,10 @@ export class ImportService {
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
unitPriceInAssetProfileCurrency: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
)
|
||||
userId: account?.userId,
|
||||
valueInBaseCurrency: value
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -146,14 +146,14 @@ export class ImportService {
|
||||
}
|
||||
|
||||
public async import({
|
||||
accountsDto,
|
||||
accountsWithBalancesDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
accountsWithBalancesDto: ImportDataDto['accounts'];
|
||||
activitiesDto: ImportDataDto['activities'];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
user: UserWithSettings;
|
||||
@ -161,12 +161,12 @@ export class ImportService {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
if (!isDryRun && accountsWithBalancesDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
in: accountsWithBalancesDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
@ -175,14 +175,19 @@ export class ImportService {
|
||||
this.platformService.getPlatforms()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
for (const accountWithBalances of accountsWithBalancesDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find(
|
||||
(existingAccount) => existingAccount.id === account.id
|
||||
);
|
||||
const accountWithSameId = existingAccounts.find((existingAccount) => {
|
||||
return existingAccount.id === accountWithBalances.id;
|
||||
});
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||
const account: CreateAccountDto = omit(
|
||||
accountWithBalances,
|
||||
'balances'
|
||||
);
|
||||
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
@ -195,7 +200,10 @@ export class ImportService {
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: user.id } }
|
||||
balances: {
|
||||
create: accountWithBalances.balances ?? []
|
||||
},
|
||||
user: { connect: { id: user.id } }
|
||||
};
|
||||
|
||||
if (
|
||||
@ -205,7 +213,7 @@ export class ImportService {
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
platform: { connect: { id: platformId } }
|
||||
};
|
||||
}
|
||||
|
||||
@ -259,24 +267,24 @@ export class ImportService {
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accountsWithBalancesDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
|
||||
for (const activity of activitiesExtendedWithErrors) {
|
||||
const accountId = activity.accountId;
|
||||
const comment = activity.comment;
|
||||
const currency = activity.currency;
|
||||
const date = activity.date;
|
||||
const error = activity.error;
|
||||
let fee = activity.fee;
|
||||
const fee = activity.fee;
|
||||
const quantity = activity.quantity;
|
||||
const SymbolProfile = activity.SymbolProfile;
|
||||
const type = activity.type;
|
||||
let unitPrice = activity.unitPrice;
|
||||
const unitPrice = activity.unitPrice;
|
||||
|
||||
const assetProfile = assetProfiles[
|
||||
getAssetProfileIdentifier({
|
||||
@ -284,7 +292,6 @@ export class ImportService {
|
||||
symbol: SymbolProfile.symbol
|
||||
})
|
||||
] ?? {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
@ -300,6 +307,7 @@ export class ImportService {
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isActive,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -319,35 +327,6 @@ export class ImportService {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||
// Convert the unit price and fee to the asset currency if the imported
|
||||
// activity is in a different currency
|
||||
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
unitPrice,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
|
||||
if (!isNumber(unitPrice)) {
|
||||
throw new Error(
|
||||
`activities.${index} historical exchange rate at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)} is not available from "${SymbolProfile.currency}" to "${
|
||||
assetProfile.currency
|
||||
}"`
|
||||
);
|
||||
}
|
||||
|
||||
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
@ -375,6 +354,7 @@ export class ImportService {
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isActive,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -398,6 +378,7 @@ export class ImportService {
|
||||
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
@ -421,7 +402,7 @@ export class ImportService {
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: user.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
@ -437,21 +418,8 @@ export class ImportService {
|
||||
...order,
|
||||
error,
|
||||
value,
|
||||
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
),
|
||||
// @ts-ignore
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency,
|
||||
date
|
||||
)
|
||||
SymbolProfile: assetProfile
|
||||
});
|
||||
}
|
||||
|
||||
@ -517,7 +485,9 @@ export class ImportService {
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.comment === comment &&
|
||||
(activity.currency === currency ||
|
||||
activity.SymbolProfile.currency === currency) &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
@ -535,6 +505,7 @@ export class ImportService {
|
||||
return {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
@ -542,7 +513,6 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
activitiesCount: undefined,
|
||||
@ -550,8 +520,10 @@ export class ImportService {
|
||||
assetSubClass: undefined,
|
||||
countries: undefined,
|
||||
createdAt: undefined,
|
||||
currency: undefined,
|
||||
holdings: undefined,
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
sectors: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
@ -586,7 +558,7 @@ export class ImportService {
|
||||
const assetProfiles: {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const dataSources = await this.dataProviderService.getDataSources();
|
||||
const dataSources = await this.dataProviderService.getDataSources({ user });
|
||||
|
||||
for (const [
|
||||
index,
|
||||
@ -629,12 +601,6 @@ export class ImportService {
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
|
||||
PlatformModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInResponseModule,
|
||||
UserModule
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
@ -13,7 +14,6 @@ import {
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
@ -21,13 +21,8 @@ import {
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
SubscriptionOffer
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
@ -46,6 +41,7 @@ export class InfoService {
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -68,9 +64,9 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
isReadOnlyMode = await this.propertyService.getByKey<boolean>(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
@ -85,9 +81,9 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.countriesOfSubscribers =
|
||||
((await this.propertyService.getByKey(
|
||||
(await this.propertyService.getByKey<string[]>(
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||
)) as string[]) ?? [];
|
||||
)) ?? [];
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
@ -101,7 +97,7 @@ export class InfoService {
|
||||
isUserSignupEnabled,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptionOffers
|
||||
subscriptionOffer
|
||||
] = await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
@ -110,7 +106,7 @@ export class InfoService {
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getStatistics(),
|
||||
this.getSubscriptionOffers()
|
||||
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
|
||||
]);
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
@ -125,7 +121,7 @@ export class InfoService {
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptionOffers,
|
||||
subscriptionOffer,
|
||||
baseCurrency: DEFAULT_CURRENCY,
|
||||
currencies: this.exchangeRateDataService.getCurrencies()
|
||||
};
|
||||
@ -137,11 +133,11 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
analytics: {
|
||||
lastRequestAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
@ -220,7 +216,7 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -234,15 +230,15 @@ export class InfoService {
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
return await this.propertyService.getByKey<string>(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
);
|
||||
}
|
||||
|
||||
private async getDemoAuthToken() {
|
||||
const demoUserId = (await this.propertyService.getByKey(
|
||||
const demoUserId = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_DEMO_USER_ID
|
||||
)) as string;
|
||||
);
|
||||
|
||||
if (demoUserId) {
|
||||
return this.jwtService.sign({
|
||||
@ -299,25 +295,12 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptionOffers(): Promise<{
|
||||
[offer in SubscriptionOfferKey]: SubscriptionOffer;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
private async getUptime(): Promise<number> {
|
||||
{
|
||||
try {
|
||||
const monitorId = (await this.propertyService.getByKey(
|
||||
const monitorId = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||
)) as string;
|
||||
);
|
||||
|
||||
const { data } = await fetch(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
|
@ -27,12 +27,12 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
accountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsOptional()
|
||||
@ -49,8 +49,8 @@ export class CreateOrderDto {
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
@IsEnum(DataSource)
|
||||
@IsOptional()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
|
@ -9,11 +9,13 @@ export interface Activities {
|
||||
}
|
||||
|
||||
export interface Activity extends Order {
|
||||
Account?: AccountWithPlatform;
|
||||
account?: AccountWithPlatform;
|
||||
error?: ActivityError;
|
||||
feeInAssetProfileCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
SymbolProfile?: EnhancedSymbolProfile;
|
||||
tags?: Tag[];
|
||||
unitPriceInAssetProfileCurrency: number;
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
|
@ -53,14 +53,19 @@ export class OrderController {
|
||||
@Delete()
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async deleteOrders(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<number> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -97,7 +102,7 @@ export class OrderController {
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@ -150,7 +155,7 @@ export class OrderController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getOrderById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('id') id: string
|
||||
): Promise<Activity> {
|
||||
const impersonationUserId =
|
||||
@ -209,7 +214,7 @@ export class OrderController {
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
user: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -264,7 +269,7 @@ export class OrderController {
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
@ -282,7 +287,7 @@ export class OrderController {
|
||||
name: data.symbol
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id
|
||||
|
@ -7,8 +7,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
@ -100,10 +100,10 @@ export class OrderService {
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput;
|
||||
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
|
||||
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
@ -144,9 +144,9 @@ export class OrderService {
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
@ -179,7 +179,7 @@ export class OrderService {
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
account,
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
@ -475,8 +475,8 @@ export class OrderService {
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
{ account: null },
|
||||
{ account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
@ -487,10 +487,9 @@ export class OrderService {
|
||||
take,
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
account: {
|
||||
include: {
|
||||
Platform: true
|
||||
platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@ -531,24 +530,46 @@ export class OrderService {
|
||||
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
const [
|
||||
feeInAssetProfileCurrency,
|
||||
feeInBaseCurrency,
|
||||
unitPriceInAssetProfileCurrency,
|
||||
valueInBaseCurrency
|
||||
] = await Promise.all([
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.unitPrice,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
...order,
|
||||
feeInAssetProfileCurrency,
|
||||
feeInBaseCurrency,
|
||||
unitPriceInAssetProfileCurrency,
|
||||
value,
|
||||
feeInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
),
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency:
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
)
|
||||
valueInBaseCurrency,
|
||||
SymbolProfile: assetProfile
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -628,8 +649,8 @@ export class OrderService {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
if (data.account?.connect?.id_userId?.id === null) {
|
||||
data.account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -54,7 +54,7 @@ export class PlatformService {
|
||||
await this.prismaService.platform.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { Account: true }
|
||||
select: { accounts: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -64,7 +64,7 @@ export class PlatformService {
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
accountCount: _count.Account
|
||||
accountCount: _count.accounts
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -4,12 +4,17 @@ import {
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/common/models';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
export class MwrPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(): PortfolioSnapshot {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
protected getPerformanceCalculationType() {
|
||||
return PerformanceCalculationType.MWR;
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({}: {
|
||||
end: Date;
|
||||
exchangeRates: { [dateString: string]: number };
|
||||
|
@ -6,10 +6,14 @@ export const activityDummyData = {
|
||||
comment: undefined,
|
||||
createdAt: new Date(),
|
||||
currency: undefined,
|
||||
fee: undefined,
|
||||
feeInAssetProfileCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
id: undefined,
|
||||
isDraft: false,
|
||||
symbolProfileId: undefined,
|
||||
unitPrice: undefined,
|
||||
unitPriceInAssetProfileCurrency: undefined,
|
||||
updatedAt: new Date(),
|
||||
userId: undefined,
|
||||
value: undefined,
|
||||
@ -24,6 +28,7 @@ export const symbolProfileDummyData = {
|
||||
createdAt: undefined,
|
||||
holdings: [],
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
sectors: [],
|
||||
updatedAt: undefined
|
||||
};
|
||||
|
@ -5,17 +5,15 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||
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
|
||||
}
|
||||
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
|
||||
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
|
||||
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioCalculatorFactory {
|
||||
@ -44,7 +42,7 @@ export class PortfolioCalculatorFactory {
|
||||
}): PortfolioCalculator {
|
||||
switch (calculationType) {
|
||||
case PerformanceCalculationType.MWR:
|
||||
return new MWRPortfolioCalculator({
|
||||
return new MwrPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
@ -56,19 +54,49 @@ export class PortfolioCalculatorFactory {
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.ROAI:
|
||||
return new RoaiPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.ROI:
|
||||
return new RoiPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.TWR:
|
||||
return new TWRPortfolioCalculator({
|
||||
return new TwrPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
currentRateService: this.currentRateService,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
default:
|
||||
throw new Error('Invalid calculation type');
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Big } from 'big.js';
|
||||
@ -49,7 +50,7 @@ import {
|
||||
min,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { isNumber, sortBy, sum, uniq, uniqBy } from 'lodash';
|
||||
import { isNumber, sortBy, sum, uniqBy } from 'lodash';
|
||||
|
||||
export abstract class PortfolioCalculator {
|
||||
protected static readonly ENABLE_LOGGING = false;
|
||||
@ -112,12 +113,12 @@ export abstract class PortfolioCalculator {
|
||||
.map(
|
||||
({
|
||||
date,
|
||||
fee,
|
||||
feeInAssetProfileCurrency,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags = [],
|
||||
type,
|
||||
unitPrice
|
||||
unitPriceInAssetProfileCurrency
|
||||
}) => {
|
||||
if (isBefore(date, dateOfFirstActivity)) {
|
||||
dateOfFirstActivity = date;
|
||||
@ -134,9 +135,9 @@ export abstract class PortfolioCalculator {
|
||||
tags,
|
||||
type,
|
||||
date: format(date, DATE_FORMAT),
|
||||
fee: new Big(fee),
|
||||
fee: new Big(feeInAssetProfileCurrency),
|
||||
quantity: new Big(quantity),
|
||||
unitPrice: new Big(unitPrice)
|
||||
unitPrice: new Big(unitPriceInAssetProfileCurrency)
|
||||
};
|
||||
}
|
||||
)
|
||||
@ -222,7 +223,7 @@ export abstract class PortfolioCalculator {
|
||||
|
||||
const exchangeRatesByCurrency =
|
||||
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||
currencies: uniq(Object.values(currencies)),
|
||||
currencies: Array.from(new Set(Object.values(currencies))),
|
||||
endDate: endOfDay(this.endDate),
|
||||
startDate: this.startDate,
|
||||
targetCurrency: this.currency
|
||||
@ -623,6 +624,8 @@ export abstract class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
|
||||
|
||||
public getDataProviderInfos() {
|
||||
return this.dataProviderInfos;
|
||||
}
|
||||
@ -899,8 +902,8 @@ export abstract class PortfolioCalculator {
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
|
||||
for (const {
|
||||
fee,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags,
|
||||
@ -1073,6 +1076,7 @@ export abstract class PortfolioCalculator {
|
||||
// Compute in the background
|
||||
this.portfolioSnapshotService.addJobToQueue({
|
||||
data: {
|
||||
calculationType: this.getPerformanceCalculationType(),
|
||||
filters: this.filters,
|
||||
userCurrency: this.currency,
|
||||
userId: this.userId
|
||||
@ -1089,6 +1093,7 @@ export abstract class PortfolioCalculator {
|
||||
// Wait for computation
|
||||
await this.portfolioSnapshotService.addJobToQueue({
|
||||
data: {
|
||||
calculationType: this.getPerformanceCalculationType(),
|
||||
filters: this.filters,
|
||||
userCurrency: this.currency,
|
||||
userId: this.userId
|
||||
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-22'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,12 +99,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 142.9
|
||||
unitPriceInAssetProfileCurrency: 142.9
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.65,
|
||||
feeInAssetProfileCurrency: 1.65,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -116,12 +114,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -131,13 +129,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-22'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,12 +99,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 142.9
|
||||
unitPriceInAssetProfileCurrency: 142.9
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.65,
|
||||
feeInAssetProfileCurrency: 1.65,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -116,13 +114,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.55,
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 136.6
|
||||
unitPriceInAssetProfileCurrency: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
@ -195,5 +193,83 @@ describe('PortfolioCalculator', () => {
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
|
||||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Bâloise Holding AG',
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPriceInAssetProfileCurrency: 135.0
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
|
||||
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
|
||||
({ date }) => {
|
||||
return date === '2021-11-30';
|
||||
}
|
||||
);
|
||||
|
||||
// Closing price on 2021-11-30: 136.6
|
||||
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
|
||||
});
|
||||
|
||||
it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => {
|
||||
jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
feeInAssetProfileCurrency: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Bâloise Holding AG',
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPriceInAssetProfileCurrency: 135.0
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
|
||||
const snapshotOnBuyDate = portfolioSnapshot.historicalData.find(
|
||||
({ date }) => {
|
||||
return date === '2021-11-30';
|
||||
}
|
||||
);
|
||||
|
||||
// Closing price on 2021-11-30: 136.6
|
||||
expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,241 @@
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
loadActivityExportFile,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import { 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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
import { join } from 'path';
|
||||
|
||||
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/queues/portfolio-snapshot/portfolio-snapshot.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
|
||||
return PortfolioSnapshotServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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 activityDtos: CreateOrderDto[];
|
||||
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
|
||||
let portfolioSnapshotService: PortfolioSnapshotService;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeAll(() => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
portfolioSnapshotService = new PortfolioSnapshotService(null);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
portfolioSnapshotService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy (in EUR)', async () => {
|
||||
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
|
||||
|
||||
const activities: Activity[] = activityDtos.map((activity) => ({
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: 4.46,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Bitcoin',
|
||||
symbol: activity.symbol
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: 44558.42
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: portfolioSnapshot.historicalData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
expect(portfolioSnapshot.historicalData[0]).toEqual({
|
||||
date: '2021-12-11',
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: 0,
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
netWorth: 0,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 0,
|
||||
totalInvestmentValueWithCurrencyEffect: 0,
|
||||
value: 0,
|
||||
valueWithCurrencyEffect: 0
|
||||
});
|
||||
|
||||
/**
|
||||
* Closing price on 2021-12-12: 50098.3
|
||||
*/
|
||||
expect(portfolioSnapshot.historicalData[1]).toEqual({
|
||||
date: '2021-12-12',
|
||||
investmentValueWithCurrencyEffect: 44558.42,
|
||||
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
netPerformanceWithCurrencyEffect: 5535.42,
|
||||
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 44558.42,
|
||||
totalInvestmentValueWithCurrencyEffect: 44558.42,
|
||||
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
valueWithCurrencyEffect: 50098.3
|
||||
});
|
||||
|
||||
expect(
|
||||
portfolioSnapshot.historicalData[
|
||||
portfolioSnapshot.historicalData.length - 1
|
||||
]
|
||||
).toEqual({
|
||||
date: '2022-01-14',
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: -1463.18,
|
||||
netPerformanceInPercentage: -0.032837340282712,
|
||||
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
|
||||
netPerformanceWithCurrencyEffect: -1463.18,
|
||||
netWorth: 43099.7,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 44558.42,
|
||||
totalInvestmentValueWithCurrencyEffect: 44558.42,
|
||||
value: 43099.7,
|
||||
valueWithCurrencyEffect: 43099.7
|
||||
});
|
||||
|
||||
expect(portfolioSnapshot).toMatchObject({
|
||||
currentValueInBaseCurrency: new Big('43099.7'),
|
||||
errors: [],
|
||||
hasErrors: false,
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('44558.42'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.46'),
|
||||
feeInBaseCurrency: new Big('4.46'),
|
||||
firstBuyDate: '2021-12-12',
|
||||
grossPerformance: new Big('-1458.72'),
|
||||
grossPerformancePercentage: new Big('-0.03273724696701543726'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.03273724696701543726'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
|
||||
investment: new Big('44558.42'),
|
||||
investmentWithCurrencyEffect: new Big('44558.42'),
|
||||
netPerformance: new Big('-1463.18'),
|
||||
netPerformancePercentage: new Big('-0.03283734028271199921'),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {
|
||||
max: new Big('-0.03283734028271199921')
|
||||
},
|
||||
netPerformanceWithCurrencyEffectMap: {
|
||||
max: new Big('-1463.18')
|
||||
},
|
||||
marketPrice: 43099.7,
|
||||
marketPriceInBaseCurrency: 43099.7,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('44558.42'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('43099.7')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('4.46'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('44558.42'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-12-12', investment: new Big('44558.42') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-12-01', investment: 44558.42 },
|
||||
{ date: '2022-01-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -105,7 +103,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2015-01-01'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -115,12 +113,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 320.43
|
||||
unitPriceInAssetProfileCurrency: 320.43
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2017-12-31'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -130,13 +128,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 14156.4
|
||||
unitPriceInAssetProfileCurrency: 14156.4
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -0,0 +1,241 @@
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
loadActivityExportFile,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import { 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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
import { join } from 'path';
|
||||
|
||||
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/queues/portfolio-snapshot/portfolio-snapshot.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
|
||||
return PortfolioSnapshotServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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 activityDtos: CreateOrderDto[];
|
||||
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
|
||||
let portfolioSnapshotService: PortfolioSnapshotService;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeAll(() => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
portfolioSnapshotService = new PortfolioSnapshotService(null);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
portfolioSnapshotService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy (in USD)', async () => {
|
||||
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
|
||||
|
||||
const activities: Activity[] = activityDtos.map((activity) => ({
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: 4.46,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Bitcoin',
|
||||
symbol: activity.symbol
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: 44558.42
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: portfolioSnapshot.historicalData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
expect(portfolioSnapshot.historicalData[0]).toEqual({
|
||||
date: '2021-12-11',
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: 0,
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
netWorth: 0,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 0,
|
||||
totalInvestmentValueWithCurrencyEffect: 0,
|
||||
value: 0,
|
||||
valueWithCurrencyEffect: 0
|
||||
});
|
||||
|
||||
/**
|
||||
* Closing price on 2021-12-12: 50098.3
|
||||
*/
|
||||
expect(portfolioSnapshot.historicalData[1]).toEqual({
|
||||
date: '2021-12-12',
|
||||
investmentValueWithCurrencyEffect: 44558.42,
|
||||
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 44558.42,
|
||||
totalInvestmentValueWithCurrencyEffect: 44558.42,
|
||||
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
valueWithCurrencyEffect: 50098.3
|
||||
});
|
||||
|
||||
expect(
|
||||
portfolioSnapshot.historicalData[
|
||||
portfolioSnapshot.historicalData.length - 1
|
||||
]
|
||||
).toEqual({
|
||||
date: '2022-01-14',
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: -1463.18,
|
||||
netPerformanceInPercentage: -0.032837340282712,
|
||||
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
|
||||
netPerformanceWithCurrencyEffect: -1463.18,
|
||||
netWorth: 43099.7,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 44558.42,
|
||||
totalInvestmentValueWithCurrencyEffect: 44558.42,
|
||||
value: 43099.7,
|
||||
valueWithCurrencyEffect: 43099.7
|
||||
});
|
||||
|
||||
expect(portfolioSnapshot).toMatchObject({
|
||||
currentValueInBaseCurrency: new Big('43099.7'),
|
||||
errors: [],
|
||||
hasErrors: false,
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('44558.42'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.46'),
|
||||
feeInBaseCurrency: new Big('4.46'),
|
||||
firstBuyDate: '2021-12-12',
|
||||
grossPerformance: new Big('-1458.72'),
|
||||
grossPerformancePercentage: new Big('-0.03273724696701543726'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.03273724696701543726'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-1458.72'),
|
||||
investment: new Big('44558.42'),
|
||||
investmentWithCurrencyEffect: new Big('44558.42'),
|
||||
netPerformance: new Big('-1463.18'),
|
||||
netPerformancePercentage: new Big('-0.03283734028271199921'),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {
|
||||
max: new Big('-0.03283734028271199921')
|
||||
},
|
||||
netPerformanceWithCurrencyEffectMap: {
|
||||
max: new Big('-1463.18')
|
||||
},
|
||||
marketPrice: 43099.7,
|
||||
marketPriceInBaseCurrency: 43099.7,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('44558.42'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('43099.7')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('4.46'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('44558.42'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('44558.42'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-12-12', investment: new Big('44558.42') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-12-01', investment: 44558.42 },
|
||||
{ date: '2022-01-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-09-01'),
|
||||
fee: 49,
|
||||
feeInAssetProfileCurrency: 49,
|
||||
quantity: 0,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
||||
},
|
||||
type: 'FEE',
|
||||
unitPrice: 0
|
||||
unitPriceInAssetProfileCurrency: 0
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -18,6 +15,7 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -104,7 +102,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-03'),
|
||||
fee: 1,
|
||||
feeInAssetProfileCurrency: 1,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -114,13 +112,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'GOOGL'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 89.12
|
||||
unitPriceInAssetProfileCurrency: 89.12
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-01-01'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
||||
},
|
||||
type: 'ITEM',
|
||||
unitPrice: 500000
|
||||
unitPriceInAssetProfileCurrency: 500000
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,10 +4,7 @@ import {
|
||||
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 { 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';
|
||||
@ -17,6 +14,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -91,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-01'), // Date in future
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -101,13 +99,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
||||
},
|
||||
type: 'LIABILITY',
|
||||
unitPrice: 3000
|
||||
unitPriceInAssetProfileCurrency: 3000
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -4,20 +4,17 @@ import {
|
||||
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 { 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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -51,18 +48,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
@ -104,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-09-16'),
|
||||
fee: 19,
|
||||
feeInAssetProfileCurrency: 19,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -114,12 +99,12 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'MSFT'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 298.58
|
||||
unitPriceInAssetProfileCurrency: 298.58
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-16'),
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
@ -129,13 +114,13 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'MSFT'
|
||||
},
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: 0.62
|
||||
unitPriceInAssetProfileCurrency: 0.62
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'USD',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -1,8 +1,5 @@
|
||||
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 { 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';
|
||||
@ -12,6 +9,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@ -84,7 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities: [],
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -6,10 +6,7 @@ import {
|
||||
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 { 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';
|
||||
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
import { join } from 'path';
|
||||
@ -67,7 +65,7 @@ describe('PortfolioCalculator', () => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(
|
||||
__dirname,
|
||||
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
|
||||
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -105,18 +103,20 @@ describe('PortfolioCalculator', () => {
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: activity.fee,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: activity.currency,
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Novartis AG',
|
||||
symbol: activity.symbol
|
||||
}
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: activity.unitPrice
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
@ -6,10 +6,7 @@ import {
|
||||
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 { 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';
|
||||
@ -19,6 +16,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
import { join } from 'path';
|
||||
@ -67,7 +65,7 @@ describe('PortfolioCalculator', () => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(
|
||||
__dirname,
|
||||
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
|
||||
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -105,18 +103,20 @@ describe('PortfolioCalculator', () => {
|
||||
...activityDummyData,
|
||||
...activity,
|
||||
date: parseDate(activity.date),
|
||||
feeInAssetProfileCurrency: activity.fee,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: activity.currency,
|
||||
dataSource: activity.dataSource,
|
||||
name: 'Novartis AG',
|
||||
symbol: activity.symbol
|
||||
}
|
||||
},
|
||||
unitPriceInAssetProfileCurrency: activity.unitPrice
|
||||
}));
|
||||
|
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
calculationType: PerformanceCalculationType.ROAI,
|
||||
currency: 'CHF',
|
||||
userId: userDummyData.id
|
||||
});
|
||||
@ -145,19 +145,23 @@ describe('PortfolioCalculator', () => {
|
||||
valueWithCurrencyEffect: 0
|
||||
});
|
||||
|
||||
/**
|
||||
* Closing price on 2022-03-07 is unknown,
|
||||
* hence it uses the last unit price (2022-04-11): 87.8
|
||||
*/
|
||||
expect(portfolioSnapshot.historicalData[1]).toEqual({
|
||||
date: '2022-03-07',
|
||||
investmentValueWithCurrencyEffect: 151.6,
|
||||
netPerformance: 0,
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
netWorth: 151.6,
|
||||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
||||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
||||
netPerformanceWithCurrencyEffect: 24,
|
||||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 151.6,
|
||||
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||
value: 151.6,
|
||||
valueWithCurrencyEffect: 151.6
|
||||
value: 175.6, // 2 * 87.8 = 175.6
|
||||
valueWithCurrencyEffect: 175.6
|
||||
});
|
||||
|
||||
expect(
|
@ -0,0 +1,987 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Big } from 'big.js';
|
||||
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
|
||||
import { cloneDeep, sortBy } from 'lodash';
|
||||
|
||||
export class RoaiPortfolioCalculator extends PortfolioCalculator {
|
||||
private chartDates: string[];
|
||||
|
||||
protected calculateOverallPerformance(
|
||||
positions: TimelinePosition[]
|
||||
): PortfolioSnapshot {
|
||||
let currentValueInBaseCurrency = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||
let hasErrors = false;
|
||||
let netPerformance = new Big(0);
|
||||
let totalFeesWithCurrencyEffect = new Big(0);
|
||||
const totalInterestWithCurrencyEffect = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||
let totalTimeWeightedInvestment = new Big(0);
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.feeInBaseCurrency) {
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||
currentPosition.feeInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPosition.valueInBaseCurrency) {
|
||||
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
|
||||
currentPosition.valueInBaseCurrency
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.investment) {
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
|
||||
totalInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect.plus(
|
||||
currentPosition.investmentWithCurrencyEffect
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
|
||||
grossPerformanceWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect.plus(
|
||||
currentPosition.grossPerformanceWithCurrencyEffect
|
||||
);
|
||||
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.timeWeightedInvestment) {
|
||||
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
|
||||
currentPosition.timeWeightedInvestment
|
||||
);
|
||||
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect =
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
|
||||
currentPosition.timeWeightedInvestmentWithCurrencyEffect
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentValueInBaseCurrency,
|
||||
hasErrors,
|
||||
positions,
|
||||
totalFeesWithCurrencyEffect,
|
||||
totalInterestWithCurrencyEffect,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
activitiesCount: this.activities.filter(({ type }) => {
|
||||
return ['BUY', 'SELL'].includes(type);
|
||||
}).length,
|
||||
createdAt: new Date(),
|
||||
errors: [],
|
||||
historicalData: [],
|
||||
totalLiabilitiesWithCurrencyEffect: new Big(0),
|
||||
totalValuablesWithCurrencyEffect: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
protected getPerformanceCalculationType() {
|
||||
return PerformanceCalculationType.ROAI;
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({
|
||||
chartDateMap,
|
||||
dataSource,
|
||||
end,
|
||||
exchangeRates,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
chartDateMap?: { [date: string]: boolean };
|
||||
end: Date;
|
||||
exchangeRates: { [dateString: string]: number };
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let fees = new Big(0);
|
||||
let feesAtStartDate = new Big(0);
|
||||
let feesAtStartDateWithCurrencyEffect = new Big(0);
|
||||
let feesWithCurrencyEffect = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
|
||||
let initialValue: Big;
|
||||
let initialValueWithCurrencyEffect: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
let investmentAtStartDateWithCurrencyEffect: Big;
|
||||
const investmentValuesAccumulated: { [date: string]: Big } = {};
|
||||
const investmentValuesAccumulatedWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastAveragePriceWithCurrencyEffect = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
const timeWeightedInvestmentValuesWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
|
||||
const totalAccountBalanceInBaseCurrency = new Big(0);
|
||||
let totalDividend = new Big(0);
|
||||
let totalDividendInBaseCurrency = new Big(0);
|
||||
let totalInterest = new Big(0);
|
||||
let totalInterestInBaseCurrency = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentFromBuyTransactions = new Big(0);
|
||||
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||
let totalLiabilities = new Big(0);
|
||||
let totalLiabilitiesInBaseCurrency = new Big(0);
|
||||
let totalQuantityFromBuyTransactions = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
let totalValuables = new Big(0);
|
||||
let totalValuablesInBaseCurrency = new Big(0);
|
||||
let valueAtStartDate: Big;
|
||||
let valueAtStartDateWithCurrencyEffect: Big;
|
||||
|
||||
// Clone orders to keep the original values in this.orders
|
||||
let orders: PortfolioOrderItem[] = cloneDeep(
|
||||
this.activities.filter(({ SymbolProfile }) => {
|
||||
return SymbolProfile.symbol === symbol;
|
||||
})
|
||||
);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
feesWithCurrencyEffect: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {},
|
||||
netPerformanceValues: {},
|
||||
netPerformanceValuesWithCurrencyEffect: {},
|
||||
netPerformanceWithCurrencyEffectMap: {},
|
||||
timeWeightedInvestment: new Big(0),
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
totalInterestInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalLiabilities: new Big(0),
|
||||
totalLiabilitiesInBaseCurrency: new Big(0),
|
||||
totalValuables: new Big(0),
|
||||
totalValuablesInBaseCurrency: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(orders[0].date);
|
||||
|
||||
const endDateString = format(end, DATE_FORMAT);
|
||||
const startDateString = format(start, DATE_FORMAT);
|
||||
|
||||
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
|
||||
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
feesWithCurrencyEffect: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {},
|
||||
netPerformanceWithCurrencyEffectMap: {},
|
||||
netPerformanceValues: {},
|
||||
netPerformanceValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestment: new Big(0),
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
totalInterestInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalLiabilities: new Big(0),
|
||||
totalLiabilitiesInBaseCurrency: new Big(0),
|
||||
totalValuables: new Big(0),
|
||||
totalValuablesInBaseCurrency: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
date: startDateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
itemType: 'start',
|
||||
quantity: new Big(0),
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtStartDate
|
||||
});
|
||||
|
||||
orders.push({
|
||||
date: endDateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
itemType: 'end',
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
quantity: new Big(0),
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
let lastUnitPrice: Big;
|
||||
|
||||
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
|
||||
|
||||
for (const order of orders) {
|
||||
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
|
||||
ordersByDate[order.date].push(order);
|
||||
}
|
||||
|
||||
if (!this.chartDates) {
|
||||
this.chartDates = Object.keys(chartDateMap).sort();
|
||||
}
|
||||
|
||||
for (const dateString of this.chartDates) {
|
||||
if (dateString < startDateString) {
|
||||
continue;
|
||||
} else if (dateString > endDateString) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (ordersByDate[dateString]?.length > 0) {
|
||||
for (const order of ordersByDate[dateString]) {
|
||||
order.unitPriceFromMarketData =
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
|
||||
}
|
||||
} else {
|
||||
orders.push({
|
||||
date: dateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
quantity: new Big(0),
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
|
||||
unitPriceFromMarketData:
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
|
||||
});
|
||||
}
|
||||
|
||||
const lastOrder = orders.at(-1);
|
||||
|
||||
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
|
||||
}
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the correct
|
||||
// position
|
||||
orders = sortBy(orders, ({ date, itemType }) => {
|
||||
let sortIndex = new Date(date);
|
||||
|
||||
if (itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
} else if (itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'start';
|
||||
});
|
||||
|
||||
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'end';
|
||||
});
|
||||
|
||||
let totalInvestmentDays = 0;
|
||||
let sumOfTimeWeightedInvestments = new Big(0);
|
||||
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(
|
||||
i + 1,
|
||||
order.date,
|
||||
order.type,
|
||||
order.itemType ? `(${order.itemType})` : ''
|
||||
);
|
||||
}
|
||||
|
||||
const exchangeRateAtOrderDate = exchangeRates[order.date];
|
||||
|
||||
if (order.type === 'DIVIDEND') {
|
||||
const dividend = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalDividend = totalDividend.plus(dividend);
|
||||
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'INTEREST') {
|
||||
const interest = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalInterest = totalInterest.plus(interest);
|
||||
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
|
||||
interest.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'ITEM') {
|
||||
const valuables = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalValuables = totalValuables.plus(valuables);
|
||||
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
|
||||
valuables.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'LIABILITY') {
|
||||
const liabilities = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalLiabilities = totalLiabilities.plus(liabilities);
|
||||
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
|
||||
liabilities.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
order.unitPrice =
|
||||
indexOfStartOrder === 0
|
||||
? orders[i + 1]?.unitPrice
|
||||
: unitPriceAtStartDate;
|
||||
}
|
||||
|
||||
if (order.fee) {
|
||||
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
|
||||
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
|
||||
exchangeRateAtOrderDate ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
const unitPrice = ['BUY', 'SELL'].includes(order.type)
|
||||
? order.unitPrice
|
||||
: order.unitPriceFromMarketData;
|
||||
|
||||
if (unitPrice) {
|
||||
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
|
||||
|
||||
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
|
||||
exchangeRateAtOrderDate ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
const marketPriceInBaseCurrency =
|
||||
order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ??
|
||||
new Big(0);
|
||||
const marketPriceInBaseCurrencyWithCurrencyEffect =
|
||||
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ??
|
||||
new Big(0);
|
||||
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||
marketPriceInBaseCurrency
|
||||
);
|
||||
|
||||
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
|
||||
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
|
||||
|
||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||
|
||||
investmentAtStartDateWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect ?? new Big(0);
|
||||
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
|
||||
valueAtStartDateWithCurrencyEffect =
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||
}
|
||||
|
||||
let transactionInvestment = new Big(0);
|
||||
let transactionInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
if (order.type === 'BUY') {
|
||||
transactionInvestment = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrency)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
transactionInvestmentWithCurrencyEffect = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
totalQuantityFromBuyTransactions =
|
||||
totalQuantityFromBuyTransactions.plus(order.quantity);
|
||||
|
||||
totalInvestmentFromBuyTransactions =
|
||||
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
|
||||
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
|
||||
transactionInvestmentWithCurrencyEffect
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
if (totalUnits.gt(0)) {
|
||||
transactionInvestment = totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(getFactor(order.type));
|
||||
transactionInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(getFactor(order.type));
|
||||
}
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
|
||||
console.log(
|
||||
'transactionInvestmentWithCurrencyEffect',
|
||||
transactionInvestmentWithCurrencyEffect.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const totalInvestmentBeforeTransaction = totalInvestment;
|
||||
|
||||
const totalInvestmentBeforeTransactionWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect;
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
totalInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect.plus(
|
||||
transactionInvestmentWithCurrencyEffect
|
||||
);
|
||||
|
||||
if (i >= indexOfStartOrder && !initialValue) {
|
||||
if (
|
||||
i === indexOfStartOrder &&
|
||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||
) {
|
||||
initialValue = valueOfInvestmentBeforeTransaction;
|
||||
|
||||
initialValueWithCurrencyEffect =
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||
} else if (transactionInvestment.gt(0)) {
|
||||
initialValue = transactionInvestment;
|
||||
|
||||
initialValueWithCurrencyEffect =
|
||||
transactionInvestmentWithCurrencyEffect;
|
||||
}
|
||||
}
|
||||
|
||||
fees = fees.plus(order.feeInBaseCurrency ?? 0);
|
||||
|
||||
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
|
||||
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
|
||||
);
|
||||
|
||||
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency);
|
||||
|
||||
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
|
||||
marketPriceInBaseCurrencyWithCurrencyEffect
|
||||
);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrency
|
||||
.minus(lastAveragePrice)
|
||||
.mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformanceFromSellWithCurrencyEffect =
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||
.minus(lastAveragePriceWithCurrencyEffect)
|
||||
.mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
grossPerformanceFromSellsWithCurrencyEffect =
|
||||
grossPerformanceFromSellsWithCurrencyEffect.plus(
|
||||
grossPerformanceFromSellWithCurrencyEffect
|
||||
);
|
||||
|
||||
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestmentFromBuyTransactions.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
|
||||
0
|
||||
)
|
||||
? new Big(0)
|
||||
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSellWithCurrencyEffect',
|
||||
grossPerformanceFromSellWithCurrencyEffect.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
const newGrossPerformanceWithCurrencyEffect =
|
||||
valueOfInvestmentWithCurrencyEffect
|
||||
.minus(totalInvestmentWithCurrencyEffect)
|
||||
.plus(grossPerformanceFromSellsWithCurrencyEffect);
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
grossPerformanceWithCurrencyEffect =
|
||||
newGrossPerformanceWithCurrencyEffect;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
|
||||
grossPerformanceAtStartDateWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect;
|
||||
}
|
||||
|
||||
if (i > indexOfStartOrder) {
|
||||
// Only consider periods with an investment for the calculation of
|
||||
// the time weighted investment
|
||||
if (
|
||||
valueOfInvestmentBeforeTransaction.gt(0) &&
|
||||
['BUY', 'SELL'].includes(order.type)
|
||||
) {
|
||||
// Calculate the number of days since the previous order
|
||||
const orderDate = new Date(order.date);
|
||||
const previousOrderDate = new Date(orders[i - 1].date);
|
||||
|
||||
let daysSinceLastOrder = differenceInDays(
|
||||
orderDate,
|
||||
previousOrderDate
|
||||
);
|
||||
if (daysSinceLastOrder <= 0) {
|
||||
// The time between two activities on the same day is unknown
|
||||
// -> Set it to the smallest floating point number greater than 0
|
||||
daysSinceLastOrder = Number.EPSILON;
|
||||
}
|
||||
|
||||
// Sum up the total investment days since the start date to calculate
|
||||
// the time weighted investment
|
||||
totalInvestmentDays += daysSinceLastOrder;
|
||||
|
||||
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
|
||||
valueAtStartDate
|
||||
.minus(investmentAtStartDate)
|
||||
.plus(totalInvestmentBeforeTransaction)
|
||||
.mul(daysSinceLastOrder)
|
||||
);
|
||||
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
|
||||
valueAtStartDateWithCurrencyEffect
|
||||
.minus(investmentAtStartDateWithCurrencyEffect)
|
||||
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
|
||||
.mul(daysSinceLastOrder)
|
||||
);
|
||||
}
|
||||
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
|
||||
currentValuesWithCurrencyEffect[order.date] =
|
||||
valueOfInvestmentWithCurrencyEffect;
|
||||
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
netPerformanceValuesWithCurrencyEffect[order.date] =
|
||||
grossPerformanceWithCurrencyEffect
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||
.minus(
|
||||
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
|
||||
);
|
||||
|
||||
investmentValuesAccumulated[order.date] = totalInvestment;
|
||||
|
||||
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
|
||||
totalInvestmentWithCurrencyEffect;
|
||||
|
||||
investmentValuesWithCurrencyEffect[order.date] = (
|
||||
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
|
||||
).add(transactionInvestmentWithCurrencyEffect);
|
||||
|
||||
// If duration is effectively zero (first day), use the actual investment as the base.
|
||||
// Otherwise, use the calculated time-weighted average.
|
||||
timeWeightedInvestmentValues[order.date] =
|
||||
totalInvestmentDays > Number.EPSILON
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: totalInvestment.gt(0)
|
||||
? totalInvestment
|
||||
: new Big(0);
|
||||
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
|
||||
totalInvestmentDays > Number.EPSILON
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||
totalInvestmentDays
|
||||
)
|
||||
: totalInvestmentWithCurrencyEffect.gt(0)
|
||||
? totalInvestmentWithCurrencyEffect
|
||||
: new Big(0);
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
|
||||
console.log(
|
||||
'totalInvestmentWithCurrencyEffect',
|
||||
totalInvestmentWithCurrencyEffect.toNumber()
|
||||
);
|
||||
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
|
||||
console.log(
|
||||
'totalGrossPerformanceWithCurrencyEffect',
|
||||
grossPerformanceWithCurrencyEffect
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||
.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalGrossPerformanceWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect.minus(
|
||||
grossPerformanceAtStartDateWithCurrencyEffect
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: new Big(0);
|
||||
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||
totalInvestmentDays
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformancePercentageWithCurrencyEffect =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
|
||||
0
|
||||
)
|
||||
? totalGrossPerformanceWithCurrencyEffect.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const feesPerUnit = totalUnits.gt(0)
|
||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
|
||||
? feesWithCurrencyEffect
|
||||
.minus(feesAtStartDateWithCurrencyEffect)
|
||||
.div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentageWithCurrencyEffectMap: {
|
||||
[key: DateRange]: Big;
|
||||
} = {};
|
||||
|
||||
const netPerformanceWithCurrencyEffectMap: {
|
||||
[key: DateRange]: Big;
|
||||
} = {};
|
||||
|
||||
for (const dateRange of [
|
||||
'1d',
|
||||
'1y',
|
||||
'5y',
|
||||
'max',
|
||||
'mtd',
|
||||
'wtd',
|
||||
'ytd'
|
||||
// TODO:
|
||||
// ...eachYearOfInterval({ end, start })
|
||||
// .filter((date) => {
|
||||
// return !isThisYear(date);
|
||||
// })
|
||||
// .map((date) => {
|
||||
// return format(date, 'yyyy');
|
||||
// })
|
||||
] as DateRange[]) {
|
||||
const dateInterval = getIntervalFromDateRange(dateRange);
|
||||
const endDate = dateInterval.endDate;
|
||||
let startDate = dateInterval.startDate;
|
||||
|
||||
if (isBefore(startDate, start)) {
|
||||
startDate = start;
|
||||
}
|
||||
|
||||
const rangeEndDateString = format(endDate, DATE_FORMAT);
|
||||
const rangeStartDateString = format(startDate, DATE_FORMAT);
|
||||
|
||||
const currentValuesAtDateRangeStartWithCurrencyEffect =
|
||||
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
|
||||
|
||||
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
|
||||
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
|
||||
new Big(0);
|
||||
|
||||
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
|
||||
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
|
||||
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
|
||||
);
|
||||
|
||||
let average = new Big(0);
|
||||
let dayCount = 0;
|
||||
|
||||
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
|
||||
const date = this.chartDates[i];
|
||||
|
||||
if (date > rangeEndDateString) {
|
||||
continue;
|
||||
} else if (date < rangeStartDateString) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
|
||||
) {
|
||||
average = average.add(
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].add(
|
||||
grossPerformanceAtDateRangeStartWithCurrencyEffect
|
||||
)
|
||||
);
|
||||
|
||||
dayCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (dayCount > 0) {
|
||||
average = average.div(dayCount);
|
||||
}
|
||||
|
||||
netPerformanceWithCurrencyEffectMap[dateRange] =
|
||||
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
|
||||
// If the date range is 'max', take 0 as a start value. Otherwise,
|
||||
// the value of the end of the day of the start date is taken which
|
||||
// differs from the buying price.
|
||||
dateRange === 'max'
|
||||
? new Big(0)
|
||||
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
|
||||
new Big(0))
|
||||
) ?? new Big(0);
|
||||
|
||||
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
|
||||
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
|
||||
: new Big(0);
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
`
|
||||
${symbol}
|
||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||
2
|
||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
|
||||
2
|
||||
)}
|
||||
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Total dividend: ${totalDividend.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentageWithCurrencyEffect
|
||||
.mul(100)
|
||||
.toFixed(2)}%
|
||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Net performance: ${totalNetPerformance.toFixed(
|
||||
2
|
||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
|
||||
'max'
|
||||
].toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValues,
|
||||
currentValuesWithCurrencyEffect,
|
||||
feesWithCurrencyEffect,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
initialValue,
|
||||
initialValueWithCurrencyEffect,
|
||||
investmentValuesAccumulated,
|
||||
investmentValuesAccumulatedWithCurrencyEffect,
|
||||
investmentValuesWithCurrencyEffect,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffectMap,
|
||||
netPerformanceValues,
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffectMap,
|
||||
timeWeightedInvestmentValues,
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect,
|
||||
totalAccountBalanceInBaseCurrency,
|
||||
totalDividend,
|
||||
totalDividendInBaseCurrency,
|
||||
totalInterest,
|
||||
totalInterestInBaseCurrency,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
totalLiabilities,
|
||||
totalLiabilitiesInBaseCurrency,
|
||||
totalValuables,
|
||||
totalValuablesInBaseCurrency,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
totalGrossPerformanceWithCurrencyEffect,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
timeWeightedInvestment:
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate,
|
||||
timeWeightedInvestmentWithCurrencyEffect:
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/common/models';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
export class RoiPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(): PortfolioSnapshot {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
protected getPerformanceCalculationType() {
|
||||
return PerformanceCalculationType.ROI;
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({}: {
|
||||
end: Date;
|
||||
exchangeRates: { [dateString: string]: number };
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
@ -1,969 +1,29 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/common/models';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Big } from 'big.js';
|
||||
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
|
||||
import { cloneDeep, sortBy } from 'lodash';
|
||||
|
||||
export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
private chartDates: string[];
|
||||
|
||||
protected calculateOverallPerformance(
|
||||
positions: TimelinePosition[]
|
||||
): PortfolioSnapshot {
|
||||
let currentValueInBaseCurrency = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||
let hasErrors = false;
|
||||
let netPerformance = new Big(0);
|
||||
let totalFeesWithCurrencyEffect = new Big(0);
|
||||
const totalInterestWithCurrencyEffect = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||
let totalTimeWeightedInvestment = new Big(0);
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.feeInBaseCurrency) {
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||
currentPosition.feeInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPosition.valueInBaseCurrency) {
|
||||
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
|
||||
currentPosition.valueInBaseCurrency
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.investment) {
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
|
||||
totalInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect.plus(
|
||||
currentPosition.investmentWithCurrencyEffect
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
|
||||
grossPerformanceWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect.plus(
|
||||
currentPosition.grossPerformanceWithCurrencyEffect
|
||||
);
|
||||
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.timeWeightedInvestment) {
|
||||
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
|
||||
currentPosition.timeWeightedInvestment
|
||||
);
|
||||
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect =
|
||||
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
|
||||
currentPosition.timeWeightedInvestmentWithCurrencyEffect
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentValueInBaseCurrency,
|
||||
hasErrors,
|
||||
positions,
|
||||
totalFeesWithCurrencyEffect,
|
||||
totalInterestWithCurrencyEffect,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
activitiesCount: this.activities.filter(({ type }) => {
|
||||
return ['BUY', 'SELL'].includes(type);
|
||||
}).length,
|
||||
createdAt: new Date(),
|
||||
errors: [],
|
||||
historicalData: [],
|
||||
totalLiabilitiesWithCurrencyEffect: new Big(0),
|
||||
totalValuablesWithCurrencyEffect: new Big(0)
|
||||
};
|
||||
export class TwrPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(): PortfolioSnapshot {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({
|
||||
chartDateMap,
|
||||
dataSource,
|
||||
end,
|
||||
exchangeRates,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
chartDateMap?: { [date: string]: boolean };
|
||||
protected getPerformanceCalculationType() {
|
||||
return PerformanceCalculationType.TWR;
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({}: {
|
||||
end: Date;
|
||||
exchangeRates: { [dateString: string]: number };
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let fees = new Big(0);
|
||||
let feesAtStartDate = new Big(0);
|
||||
let feesAtStartDateWithCurrencyEffect = new Big(0);
|
||||
let feesWithCurrencyEffect = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
|
||||
let initialValue: Big;
|
||||
let initialValueWithCurrencyEffect: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
let investmentAtStartDateWithCurrencyEffect: Big;
|
||||
const investmentValuesAccumulated: { [date: string]: Big } = {};
|
||||
const investmentValuesAccumulatedWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastAveragePriceWithCurrencyEffect = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
const timeWeightedInvestmentValuesWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
|
||||
const totalAccountBalanceInBaseCurrency = new Big(0);
|
||||
let totalDividend = new Big(0);
|
||||
let totalDividendInBaseCurrency = new Big(0);
|
||||
let totalInterest = new Big(0);
|
||||
let totalInterestInBaseCurrency = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentFromBuyTransactions = new Big(0);
|
||||
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||
let totalLiabilities = new Big(0);
|
||||
let totalLiabilitiesInBaseCurrency = new Big(0);
|
||||
let totalQuantityFromBuyTransactions = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
let totalValuables = new Big(0);
|
||||
let totalValuablesInBaseCurrency = new Big(0);
|
||||
let valueAtStartDate: Big;
|
||||
let valueAtStartDateWithCurrencyEffect: Big;
|
||||
|
||||
// Clone orders to keep the original values in this.orders
|
||||
let orders: PortfolioOrderItem[] = cloneDeep(
|
||||
this.activities.filter(({ SymbolProfile }) => {
|
||||
return SymbolProfile.symbol === symbol;
|
||||
})
|
||||
);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
feesWithCurrencyEffect: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {},
|
||||
netPerformanceValues: {},
|
||||
netPerformanceValuesWithCurrencyEffect: {},
|
||||
netPerformanceWithCurrencyEffectMap: {},
|
||||
timeWeightedInvestment: new Big(0),
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
totalInterestInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalLiabilities: new Big(0),
|
||||
totalLiabilitiesInBaseCurrency: new Big(0),
|
||||
totalValuables: new Big(0),
|
||||
totalValuablesInBaseCurrency: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(orders[0].date);
|
||||
|
||||
const endDateString = format(end, DATE_FORMAT);
|
||||
const startDateString = format(start, DATE_FORMAT);
|
||||
|
||||
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
|
||||
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
feesWithCurrencyEffect: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
netPerformancePercentageWithCurrencyEffectMap: {},
|
||||
netPerformanceWithCurrencyEffectMap: {},
|
||||
netPerformanceValues: {},
|
||||
netPerformanceValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestment: new Big(0),
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
totalInterestInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalLiabilities: new Big(0),
|
||||
totalLiabilitiesInBaseCurrency: new Big(0),
|
||||
totalValuables: new Big(0),
|
||||
totalValuablesInBaseCurrency: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
date: startDateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
itemType: 'start',
|
||||
quantity: new Big(0),
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtStartDate
|
||||
});
|
||||
|
||||
orders.push({
|
||||
date: endDateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
itemType: 'end',
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
quantity: new Big(0),
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
let lastUnitPrice: Big;
|
||||
|
||||
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
|
||||
|
||||
for (const order of orders) {
|
||||
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
|
||||
ordersByDate[order.date].push(order);
|
||||
}
|
||||
|
||||
if (!this.chartDates) {
|
||||
this.chartDates = Object.keys(chartDateMap).sort();
|
||||
}
|
||||
|
||||
for (const dateString of this.chartDates) {
|
||||
if (dateString < startDateString) {
|
||||
continue;
|
||||
} else if (dateString > endDateString) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (ordersByDate[dateString]?.length > 0) {
|
||||
for (const order of ordersByDate[dateString]) {
|
||||
order.unitPriceFromMarketData =
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
|
||||
}
|
||||
} else {
|
||||
orders.push({
|
||||
date: dateString,
|
||||
fee: new Big(0),
|
||||
feeInBaseCurrency: new Big(0),
|
||||
quantity: new Big(0),
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
|
||||
unitPriceFromMarketData:
|
||||
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
|
||||
});
|
||||
}
|
||||
|
||||
const lastOrder = orders.at(-1);
|
||||
|
||||
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
|
||||
}
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the correct
|
||||
// position
|
||||
orders = sortBy(orders, ({ date, itemType }) => {
|
||||
let sortIndex = new Date(date);
|
||||
|
||||
if (itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
} else if (itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'start';
|
||||
});
|
||||
|
||||
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'end';
|
||||
});
|
||||
|
||||
let totalInvestmentDays = 0;
|
||||
let sumOfTimeWeightedInvestments = new Big(0);
|
||||
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(
|
||||
i + 1,
|
||||
order.date,
|
||||
order.type,
|
||||
order.itemType ? `(${order.itemType})` : ''
|
||||
);
|
||||
}
|
||||
|
||||
const exchangeRateAtOrderDate = exchangeRates[order.date];
|
||||
|
||||
if (order.type === 'DIVIDEND') {
|
||||
const dividend = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalDividend = totalDividend.plus(dividend);
|
||||
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'INTEREST') {
|
||||
const interest = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalInterest = totalInterest.plus(interest);
|
||||
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
|
||||
interest.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'ITEM') {
|
||||
const valuables = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalValuables = totalValuables.plus(valuables);
|
||||
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
|
||||
valuables.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'LIABILITY') {
|
||||
const liabilities = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalLiabilities = totalLiabilities.plus(liabilities);
|
||||
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
|
||||
liabilities.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
order.unitPrice =
|
||||
indexOfStartOrder === 0
|
||||
? orders[i + 1]?.unitPrice
|
||||
: unitPriceAtStartDate;
|
||||
}
|
||||
|
||||
if (order.fee) {
|
||||
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
|
||||
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
|
||||
exchangeRateAtOrderDate ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
const unitPrice = ['BUY', 'SELL'].includes(order.type)
|
||||
? order.unitPrice
|
||||
: order.unitPriceFromMarketData;
|
||||
|
||||
if (unitPrice) {
|
||||
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
|
||||
|
||||
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
|
||||
exchangeRateAtOrderDate ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||
order.unitPriceInBaseCurrency
|
||||
);
|
||||
|
||||
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
|
||||
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
|
||||
|
||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||
|
||||
investmentAtStartDateWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect ?? new Big(0);
|
||||
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
|
||||
valueAtStartDateWithCurrencyEffect =
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||
}
|
||||
|
||||
let transactionInvestment = new Big(0);
|
||||
let transactionInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
if (order.type === 'BUY') {
|
||||
transactionInvestment = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrency)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
transactionInvestmentWithCurrencyEffect = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
totalQuantityFromBuyTransactions =
|
||||
totalQuantityFromBuyTransactions.plus(order.quantity);
|
||||
|
||||
totalInvestmentFromBuyTransactions =
|
||||
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
|
||||
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
|
||||
transactionInvestmentWithCurrencyEffect
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
if (totalUnits.gt(0)) {
|
||||
transactionInvestment = totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(getFactor(order.type));
|
||||
transactionInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(getFactor(order.type));
|
||||
}
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
|
||||
console.log(
|
||||
'transactionInvestmentWithCurrencyEffect',
|
||||
transactionInvestmentWithCurrencyEffect.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const totalInvestmentBeforeTransaction = totalInvestment;
|
||||
|
||||
const totalInvestmentBeforeTransactionWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect;
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
totalInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect.plus(
|
||||
transactionInvestmentWithCurrencyEffect
|
||||
);
|
||||
|
||||
if (i >= indexOfStartOrder && !initialValue) {
|
||||
if (
|
||||
i === indexOfStartOrder &&
|
||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||
) {
|
||||
initialValue = valueOfInvestmentBeforeTransaction;
|
||||
|
||||
initialValueWithCurrencyEffect =
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||
} else if (transactionInvestment.gt(0)) {
|
||||
initialValue = transactionInvestment;
|
||||
|
||||
initialValueWithCurrencyEffect =
|
||||
transactionInvestmentWithCurrencyEffect;
|
||||
}
|
||||
}
|
||||
|
||||
fees = fees.plus(order.feeInBaseCurrency ?? 0);
|
||||
|
||||
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
|
||||
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
|
||||
);
|
||||
|
||||
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
|
||||
|
||||
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
|
||||
order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||
);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrency
|
||||
.minus(lastAveragePrice)
|
||||
.mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformanceFromSellWithCurrencyEffect =
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||
.minus(lastAveragePriceWithCurrencyEffect)
|
||||
.mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
grossPerformanceFromSellsWithCurrencyEffect =
|
||||
grossPerformanceFromSellsWithCurrencyEffect.plus(
|
||||
grossPerformanceFromSellWithCurrencyEffect
|
||||
);
|
||||
|
||||
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestmentFromBuyTransactions.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
|
||||
0
|
||||
)
|
||||
? new Big(0)
|
||||
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSellWithCurrencyEffect',
|
||||
grossPerformanceFromSellWithCurrencyEffect.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
const newGrossPerformanceWithCurrencyEffect =
|
||||
valueOfInvestmentWithCurrencyEffect
|
||||
.minus(totalInvestmentWithCurrencyEffect)
|
||||
.plus(grossPerformanceFromSellsWithCurrencyEffect);
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
grossPerformanceWithCurrencyEffect =
|
||||
newGrossPerformanceWithCurrencyEffect;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
|
||||
grossPerformanceAtStartDateWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect;
|
||||
}
|
||||
|
||||
if (i > indexOfStartOrder) {
|
||||
// Only consider periods with an investment for the calculation of
|
||||
// the time weighted investment
|
||||
if (
|
||||
valueOfInvestmentBeforeTransaction.gt(0) &&
|
||||
['BUY', 'SELL'].includes(order.type)
|
||||
) {
|
||||
// Calculate the number of days since the previous order
|
||||
const orderDate = new Date(order.date);
|
||||
const previousOrderDate = new Date(orders[i - 1].date);
|
||||
|
||||
let daysSinceLastOrder = differenceInDays(
|
||||
orderDate,
|
||||
previousOrderDate
|
||||
);
|
||||
if (daysSinceLastOrder <= 0) {
|
||||
// The time between two activities on the same day is unknown
|
||||
// -> Set it to the smallest floating point number greater than 0
|
||||
daysSinceLastOrder = Number.EPSILON;
|
||||
}
|
||||
|
||||
// Sum up the total investment days since the start date to calculate
|
||||
// the time weighted investment
|
||||
totalInvestmentDays += daysSinceLastOrder;
|
||||
|
||||
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
|
||||
valueAtStartDate
|
||||
.minus(investmentAtStartDate)
|
||||
.plus(totalInvestmentBeforeTransaction)
|
||||
.mul(daysSinceLastOrder)
|
||||
);
|
||||
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
|
||||
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
|
||||
valueAtStartDateWithCurrencyEffect
|
||||
.minus(investmentAtStartDateWithCurrencyEffect)
|
||||
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
|
||||
.mul(daysSinceLastOrder)
|
||||
);
|
||||
}
|
||||
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
|
||||
currentValuesWithCurrencyEffect[order.date] =
|
||||
valueOfInvestmentWithCurrencyEffect;
|
||||
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
netPerformanceValuesWithCurrencyEffect[order.date] =
|
||||
grossPerformanceWithCurrencyEffect
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||
.minus(
|
||||
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
|
||||
);
|
||||
|
||||
investmentValuesAccumulated[order.date] = totalInvestment;
|
||||
|
||||
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
|
||||
totalInvestmentWithCurrencyEffect;
|
||||
|
||||
investmentValuesWithCurrencyEffect[order.date] = (
|
||||
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
|
||||
).add(transactionInvestmentWithCurrencyEffect);
|
||||
|
||||
timeWeightedInvestmentValues[order.date] =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: new Big(0);
|
||||
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||
totalInvestmentDays
|
||||
)
|
||||
: new Big(0);
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
|
||||
console.log(
|
||||
'totalInvestmentWithCurrencyEffect',
|
||||
totalInvestmentWithCurrencyEffect.toNumber()
|
||||
);
|
||||
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
|
||||
console.log(
|
||||
'totalGrossPerformanceWithCurrencyEffect',
|
||||
grossPerformanceWithCurrencyEffect
|
||||
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||
.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalGrossPerformanceWithCurrencyEffect =
|
||||
grossPerformanceWithCurrencyEffect.minus(
|
||||
grossPerformanceAtStartDateWithCurrencyEffect
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: new Big(0);
|
||||
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||
totalInvestmentDays
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformancePercentageWithCurrencyEffect =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
|
||||
0
|
||||
)
|
||||
? totalGrossPerformanceWithCurrencyEffect.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const feesPerUnit = totalUnits.gt(0)
|
||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
|
||||
? feesWithCurrencyEffect
|
||||
.minus(feesAtStartDateWithCurrencyEffect)
|
||||
.div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentageWithCurrencyEffectMap: {
|
||||
[key: DateRange]: Big;
|
||||
} = {};
|
||||
|
||||
const netPerformanceWithCurrencyEffectMap: {
|
||||
[key: DateRange]: Big;
|
||||
} = {};
|
||||
|
||||
for (const dateRange of [
|
||||
'1d',
|
||||
'1y',
|
||||
'5y',
|
||||
'max',
|
||||
'mtd',
|
||||
'wtd',
|
||||
'ytd'
|
||||
// TODO:
|
||||
// ...eachYearOfInterval({ end, start })
|
||||
// .filter((date) => {
|
||||
// return !isThisYear(date);
|
||||
// })
|
||||
// .map((date) => {
|
||||
// return format(date, 'yyyy');
|
||||
// })
|
||||
] as DateRange[]) {
|
||||
const dateInterval = getIntervalFromDateRange(dateRange);
|
||||
const endDate = dateInterval.endDate;
|
||||
let startDate = dateInterval.startDate;
|
||||
|
||||
if (isBefore(startDate, start)) {
|
||||
startDate = start;
|
||||
}
|
||||
|
||||
const rangeEndDateString = format(endDate, DATE_FORMAT);
|
||||
const rangeStartDateString = format(startDate, DATE_FORMAT);
|
||||
|
||||
const currentValuesAtDateRangeStartWithCurrencyEffect =
|
||||
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
|
||||
|
||||
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
|
||||
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
|
||||
new Big(0);
|
||||
|
||||
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
|
||||
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
|
||||
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
|
||||
);
|
||||
|
||||
let average = new Big(0);
|
||||
let dayCount = 0;
|
||||
|
||||
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
|
||||
const date = this.chartDates[i];
|
||||
|
||||
if (date > rangeEndDateString) {
|
||||
continue;
|
||||
} else if (date < rangeStartDateString) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
|
||||
) {
|
||||
average = average.add(
|
||||
investmentValuesAccumulatedWithCurrencyEffect[date].add(
|
||||
grossPerformanceAtDateRangeStartWithCurrencyEffect
|
||||
)
|
||||
);
|
||||
|
||||
dayCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (dayCount > 0) {
|
||||
average = average.div(dayCount);
|
||||
}
|
||||
|
||||
netPerformanceWithCurrencyEffectMap[dateRange] =
|
||||
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
|
||||
// If the date range is 'max', take 0 as a start value. Otherwise,
|
||||
// the value of the end of the day of the start date is taken which
|
||||
// differs from the buying price.
|
||||
dateRange === 'max'
|
||||
? new Big(0)
|
||||
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
|
||||
new Big(0))
|
||||
) ?? new Big(0);
|
||||
|
||||
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
|
||||
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
|
||||
: new Big(0);
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
`
|
||||
${symbol}
|
||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||
2
|
||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
|
||||
2
|
||||
)}
|
||||
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Total dividend: ${totalDividend.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentageWithCurrencyEffect
|
||||
.mul(100)
|
||||
.toFixed(2)}%
|
||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Net performance: ${totalNetPerformance.toFixed(
|
||||
2
|
||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
|
||||
'max'
|
||||
].toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValues,
|
||||
currentValuesWithCurrencyEffect,
|
||||
feesWithCurrencyEffect,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
initialValue,
|
||||
initialValueWithCurrencyEffect,
|
||||
investmentValuesAccumulated,
|
||||
investmentValuesAccumulatedWithCurrencyEffect,
|
||||
investmentValuesWithCurrencyEffect,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffectMap,
|
||||
netPerformanceValues,
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffectMap,
|
||||
timeWeightedInvestmentValues,
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect,
|
||||
totalAccountBalanceInBaseCurrency,
|
||||
totalDividend,
|
||||
totalDividendInBaseCurrency,
|
||||
totalInterest,
|
||||
totalInterestInBaseCurrency,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
totalLiabilities,
|
||||
totalLiabilitiesInBaseCurrency,
|
||||
totalValuables,
|
||||
totalValuablesInBaseCurrency,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
totalGrossPerformanceWithCurrencyEffect,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
timeWeightedInvestment:
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate,
|
||||
timeWeightedInvestmentWithCurrencyEffect:
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||
};
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,10 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
return { marketPrice: 14156.4 };
|
||||
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||
return { marketPrice: 13657.2 };
|
||||
} else if (isSameDay(parseDate('2021-12-12'), date)) {
|
||||
return { marketPrice: 50098.3 };
|
||||
} else if (isSameDay(parseDate('2022-01-14'), date)) {
|
||||
return { marketPrice: 43099.7 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
@ -6,6 +6,7 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { DateQuery } from './interfaces/date-query.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
|
||||
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
@ -25,33 +26,40 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
},
|
||||
getRange: ({
|
||||
assetProfileIdentifiers,
|
||||
dateRangeEnd,
|
||||
dateRangeStart
|
||||
dateQuery
|
||||
}: {
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
dateQuery: DateQuery;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}) => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
createdAt: dateQuery.gte,
|
||||
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||
date: dateRangeStart,
|
||||
date: dateQuery.gte,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
state: 'CLOSE',
|
||||
symbol: assetProfileIdentifiers[0].symbol
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
createdAt: dateQuery.lt,
|
||||
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||
date: dateRangeEnd,
|
||||
date: dateQuery.lt,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE',
|
||||
symbol: assetProfileIdentifiers[0].symbol
|
||||
}
|
||||
]);
|
||||
},
|
||||
getRangeCount: ({}: {
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
}) => {
|
||||
return Promise.resolve<number>(2);
|
||||
}
|
||||
};
|
||||
})
|
||||
@ -128,9 +136,15 @@ describe('CurrentRateService', () => {
|
||||
values: [
|
||||
{
|
||||
dataSource: 'YAHOO',
|
||||
date: undefined,
|
||||
date: new Date('2020-01-01T00:00:00.000Z'),
|
||||
marketPrice: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
dataSource: 'YAHOO',
|
||||
date: new Date('2020-01-02T00:00:00.000Z'),
|
||||
marketPrice: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
@ -21,6 +21,8 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentRateService {
|
||||
private static readonly MARKET_DATA_PAGE_SIZE = 50000;
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@ -41,42 +43,37 @@ export class CurrentRateService {
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<GetValueObject[]>[] = [];
|
||||
const quoteErrors: ResponseError['errors'] = [];
|
||||
const today = resetHours(new Date());
|
||||
const values: GetValueObject[] = [];
|
||||
|
||||
if (includesToday) {
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
const quotesBySymbol = await this.dataProviderService.getQuotes({
|
||||
items: dataGatheringItems,
|
||||
user: this.request?.user
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of dataGatheringItems) {
|
||||
if (dataResultProvider?.[symbol]?.dataProviderInfo) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
for (const { dataSource, symbol } of dataGatheringItems) {
|
||||
const quote = quotesBySymbol[symbol];
|
||||
|
||||
if (dataResultProvider?.[symbol]?.marketPrice) {
|
||||
result.push({
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: dataResultProvider?.[symbol]?.marketPrice
|
||||
});
|
||||
} else {
|
||||
quoteErrors.push({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
if (quote?.dataProviderInfo) {
|
||||
dataProviderInfos.push(quote.dataProviderInfo);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
if (quote?.marketPrice) {
|
||||
values.push({
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: quote.marketPrice
|
||||
});
|
||||
} else {
|
||||
quoteErrors.push({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfileIdentifiers: AssetProfileIdentifier[] =
|
||||
@ -84,34 +81,42 @@ export class CurrentRateService {
|
||||
return { dataSource, symbol };
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
assetProfileIdentifiers,
|
||||
dateQuery
|
||||
})
|
||||
.then((data) => {
|
||||
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
date,
|
||||
marketPrice,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const values = await Promise.all(promises).then((array) => {
|
||||
return array.flat();
|
||||
const marketDataCount = await this.marketDataService.getRangeCount({
|
||||
assetProfileIdentifiers,
|
||||
dateQuery
|
||||
});
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < marketDataCount;
|
||||
i += CurrentRateService.MARKET_DATA_PAGE_SIZE
|
||||
) {
|
||||
// Use page size to limit the number of records fetched at once
|
||||
const data = await this.marketDataService.getRange({
|
||||
assetProfileIdentifiers,
|
||||
dateQuery,
|
||||
skip: i,
|
||||
take: CurrentRateService.MARKET_DATA_PAGE_SIZE
|
||||
});
|
||||
|
||||
values.push(
|
||||
...data.map(({ dataSource, date, marketPrice, symbol }) => ({
|
||||
dataSource,
|
||||
date,
|
||||
marketPrice,
|
||||
symbol
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const response: GetValuesObject = {
|
||||
dataProviderInfos,
|
||||
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}),
|
||||
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||
values: uniqBy(values, ({ date, symbol }) => {
|
||||
return `${date}-${symbol}`;
|
||||
})
|
||||
};
|
||||
|
||||
if (!isEmpty(quoteErrors)) {
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioHoldingResponse,
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
@ -56,7 +57,6 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||
|
||||
@ -365,6 +365,33 @@ export class PortfolioController {
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('holding/:dataSource/:symbol')
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getHolding(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingResponse> {
|
||||
const holding = await this.portfolioService.getHolding({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return holding;
|
||||
}
|
||||
|
||||
@Get('holdings')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@ -386,19 +413,19 @@ export class PortfolioController {
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
const holdings = await this.portfolioService.getHoldings({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
query: filterBySearchQuery,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { holdings: Object.values(holdings) };
|
||||
return { holdings };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@ -427,7 +454,8 @@ export class PortfolioController {
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId,
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
@ -583,6 +611,9 @@ export class PortfolioController {
|
||||
return performanceInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@ -592,12 +623,13 @@ export class PortfolioController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
): Promise<PortfolioHoldingResponse> {
|
||||
const holding = await this.portfolioService.getHolding({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
@ -614,7 +646,10 @@ export class PortfolioController {
|
||||
public async getReport(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReportResponse> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
const report = await this.portfolioService.getReport({
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
@ -634,7 +669,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put('position/:dataSource/:symbol/tags')
|
||||
@Put('holding/:dataSource/:symbol/tags')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateHoldingTags(
|
||||
@ -643,11 +678,48 @@ export class PortfolioController {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
const holding = await this.portfolioService.getHolding({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put('position/:dataSource/:symbol/tags')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updatePositionTags(
|
||||
@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.getHolding({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
|
@ -9,9 +9,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
|
||||
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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -33,10 +35,12 @@ import { RulesService } from './rules.service';
|
||||
imports: [
|
||||
AccessModule,
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,16 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { createKeyv } from '@keyv/redis';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { redisStore } from 'cache-manager-redis-yet';
|
||||
import type { RedisClientOptions } from 'redis';
|
||||
|
||||
import { RedisCacheService } from './redis-cache.service';
|
||||
|
||||
@Module({
|
||||
exports: [RedisCacheService],
|
||||
imports: [
|
||||
CacheModule.registerAsync<RedisClientOptions>({
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigurationModule],
|
||||
inject: [ConfigurationService],
|
||||
useFactory: async (configurationService: ConfigurationService) => {
|
||||
@ -20,10 +19,13 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
);
|
||||
|
||||
return {
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL'),
|
||||
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
|
||||
} as RedisClientOptions;
|
||||
stores: [
|
||||
createKeyv(
|
||||
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
|
||||
)
|
||||
],
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
};
|
||||
}
|
||||
}),
|
||||
ConfigurationModule
|
||||
|
@ -2,22 +2,31 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Milliseconds } from 'cache-manager';
|
||||
import { RedisCache } from 'cache-manager-redis-yet';
|
||||
import { createHash } from 'crypto';
|
||||
import Keyv from 'keyv';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class RedisCacheService {
|
||||
private client: Keyv;
|
||||
|
||||
public constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const client = cache.store.client;
|
||||
this.client = cache.stores[0];
|
||||
|
||||
client.on('error', (error) => {
|
||||
this.client.deserialize = (value) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
Logger.error(error, 'RedisCacheService');
|
||||
});
|
||||
}
|
||||
@ -27,13 +36,18 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async getKeys(aPrefix?: string): Promise<string[]> {
|
||||
let prefix = aPrefix;
|
||||
const keys: string[] = [];
|
||||
const prefix = aPrefix;
|
||||
|
||||
if (prefix) {
|
||||
prefix = `${prefix}*`;
|
||||
}
|
||||
try {
|
||||
for await (const [key] of this.client.iterator({})) {
|
||||
if ((prefix && key.startsWith(prefix)) || !prefix) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return this.cache.store.keys(prefix);
|
||||
return keys;
|
||||
}
|
||||
|
||||
public getPortfolioSnapshotKey({
|
||||
@ -61,22 +75,36 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async isHealthy() {
|
||||
try {
|
||||
const client = this.cache.store.client;
|
||||
const testKey = '__health_check__';
|
||||
const testValue = Date.now().toString();
|
||||
|
||||
const isHealthy = await Promise.race([
|
||||
client.ping(),
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await this.set(testKey, testValue, ms('1 second'));
|
||||
const result = await this.get(testKey);
|
||||
|
||||
if (result !== testValue) {
|
||||
throw new Error('Redis health check failed: value mismatch');
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Redis health check timeout')),
|
||||
() => reject(new Error('Redis health check failed: timeout')),
|
||||
ms('2 seconds')
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
return isHealthy === 'PONG';
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error(error?.message, 'RedisCacheService');
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
await this.remove(testKey);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,16 +121,14 @@ export class RedisCacheService {
|
||||
`${this.getPortfolioSnapshotKey({ userId })}`
|
||||
);
|
||||
|
||||
for (const key of keys) {
|
||||
await this.remove(key);
|
||||
}
|
||||
return this.cache.mdel(keys);
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
return this.cache.reset();
|
||||
return this.cache.clear();
|
||||
}
|
||||
|
||||
public async set(key: string, value: string, ttl?: Milliseconds) {
|
||||
public async set(key: string, value: string, ttl?: number) {
|
||||
return this.cache.set(
|
||||
key,
|
||||
value,
|
||||
|
@ -1,80 +0,0 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
try {
|
||||
this.sitemapXml = fs.readFileSync(
|
||||
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public async getSitemapXml(@Res() response: Response): Promise<void> {
|
||||
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
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')
|
||||
: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -49,8 +49,7 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
let coupons =
|
||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||
[];
|
||||
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? [];
|
||||
|
||||
const coupon = coupons.find((currentCoupon) => {
|
||||
return currentCoupon.code === couponCode;
|
||||
|
@ -32,7 +32,7 @@ export class SubscriptionService {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2024-09-30.acacia'
|
||||
apiVersion: '2025-05-28.basil'
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -50,8 +50,7 @@ export class SubscriptionService {
|
||||
const subscriptionOffers: {
|
||||
[offer in SubscriptionOfferKey]: SubscriptionOffer;
|
||||
} =
|
||||
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||
{};
|
||||
(await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
|
||||
|
||||
const subscriptionOffer = Object.values(subscriptionOffers).find(
|
||||
(subscriptionOffer) => {
|
||||
@ -61,7 +60,7 @@ export class SubscriptionService {
|
||||
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||
user.Settings.settings.language
|
||||
}/account`,
|
||||
client_reference_id: user.id,
|
||||
line_items: [
|
||||
@ -122,7 +121,7 @@ export class SubscriptionService {
|
||||
data: {
|
||||
expiresAt,
|
||||
price,
|
||||
User: {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
@ -158,38 +157,66 @@ export class SubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
public getSubscription({
|
||||
public async getSubscription({
|
||||
createdAt,
|
||||
subscriptions
|
||||
}: {
|
||||
createdAt: UserWithSettings['createdAt'];
|
||||
subscriptions: Subscription[];
|
||||
}): UserWithSettings['subscription'] {
|
||||
}): Promise<UserWithSettings['subscription']> {
|
||||
if (subscriptions.length > 0) {
|
||||
const { expiresAt, price } = subscriptions.reduce((a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
});
|
||||
|
||||
let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
|
||||
let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default';
|
||||
|
||||
if (isBefore(createdAt, parseDate('2023-01-01'))) {
|
||||
offer = 'renewal-early-bird-2023';
|
||||
offerKey = 'renewal-early-bird-2023';
|
||||
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
|
||||
offer = 'renewal-early-bird-2024';
|
||||
offerKey = 'renewal-early-bird-2024';
|
||||
}
|
||||
|
||||
const offer = await this.getSubscriptionOffer({
|
||||
key: offerKey
|
||||
});
|
||||
|
||||
return {
|
||||
expiresAt,
|
||||
offer,
|
||||
expiresAt: isBefore(new Date(), expiresAt) ? expiresAt : undefined,
|
||||
type: isBefore(new Date(), expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
const offer = await this.getSubscriptionOffer({
|
||||
key: 'default'
|
||||
});
|
||||
|
||||
return {
|
||||
offer: 'default',
|
||||
offer,
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async getSubscriptionOffer({
|
||||
key
|
||||
}: {
|
||||
key: SubscriptionOfferKey;
|
||||
}): Promise<SubscriptionOffer> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offers: {
|
||||
[offer in SubscriptionOfferKey]: SubscriptionOffer;
|
||||
} =
|
||||
(await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
|
||||
|
||||
return {
|
||||
...offers[key],
|
||||
isRenewal: key.startsWith('renewal')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
6
apps/api/src/app/user/update-own-access-token.dto.ts
Normal file
6
apps/api/src/app/user/update-own-access-token.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOwnAccessTokenDto {
|
||||
@IsString()
|
||||
accessToken: string;
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccessTokenResponse,
|
||||
User,
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
@ -28,6 +33,7 @@ import { merge, size } from 'lodash';
|
||||
|
||||
import { DeleteOwnUserDto } from './delete-own-user.dto';
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -36,6 +42,7 @@ export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@ -47,24 +54,12 @@ export class UserController {
|
||||
public async deleteOwnUser(
|
||||
@Body() data: DeleteOwnUserDto
|
||||
): Promise<UserModel> {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
const user = await this.validateAccessToken(
|
||||
data.accessToken,
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken, id: this.request.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.deleteUser({
|
||||
accessToken: hashedAccessToken,
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
@ -85,6 +80,29 @@ export class UserController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post(':id/access-token')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateUserAccessToken(
|
||||
@Param('id') id: string
|
||||
): Promise<AccessTokenResponse> {
|
||||
return this.rotateUserAccessToken(id);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOwnAccessToken)
|
||||
@Post('access-token')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateOwnAccessToken(
|
||||
@Body() data: UpdateOwnAccessTokenDto
|
||||
): Promise<AccessTokenResponse> {
|
||||
const user = await this.validateAccessToken(
|
||||
data.accessToken,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
return this.rotateUserAccessToken(user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getUser(
|
||||
@ -164,4 +182,43 @@ export class UserController {
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
private async rotateUserAccessToken(
|
||||
userId: string
|
||||
): Promise<AccessTokenResponse> {
|
||||
const { accessToken, hashedAccessToken } =
|
||||
this.userService.generateAccessToken({
|
||||
userId
|
||||
});
|
||||
|
||||
await this.prismaService.user.update({
|
||||
data: { accessToken: hashedAccessToken },
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
private async validateAccessToken(
|
||||
accessToken: string,
|
||||
userId: string
|
||||
): Promise<UserModel> {
|
||||
const hashedAccessToken = this.userService.createAccessToken({
|
||||
password: accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken, id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
@ -16,6 +17,7 @@ import { UserService } from './user.service';
|
||||
exports: [UserService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
I18nModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
|
@ -41,6 +41,7 @@ import {
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
@ -51,11 +52,10 @@ import { sortBy, without } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private i18nService = new I18nService();
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@ -67,24 +67,44 @@ export class UserService {
|
||||
return this.prismaService.user.count(args);
|
||||
}
|
||||
|
||||
public createAccessToken(password: string, salt: string): string {
|
||||
public createAccessToken({
|
||||
password,
|
||||
salt
|
||||
}: {
|
||||
password: string;
|
||||
salt: string;
|
||||
}): string {
|
||||
const hash = createHmac('sha512', salt);
|
||||
hash.update(password);
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
public generateAccessToken({ userId }: { userId: string }) {
|
||||
const accessToken = this.createAccessToken({
|
||||
password: userId,
|
||||
salt: getRandomString(10)
|
||||
});
|
||||
|
||||
const hashedAccessToken = this.createAccessToken({
|
||||
password: accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
return { accessToken, hashedAccessToken };
|
||||
}
|
||||
|
||||
public async getUser(
|
||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
{ accounts, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
aLocale = locale
|
||||
): Promise<IUser> {
|
||||
const userData = await Promise.all([
|
||||
this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
user: true
|
||||
},
|
||||
orderBy: { alias: 'asc' },
|
||||
where: { GranteeUser: { id } }
|
||||
where: { granteeUserId: id }
|
||||
}),
|
||||
this.prismaService.order.count({
|
||||
where: { userId: id }
|
||||
@ -105,9 +125,10 @@ export class UserService {
|
||||
|
||||
let systemMessage: SystemMessage;
|
||||
|
||||
const systemMessageProperty = (await this.propertyService.getByKey(
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
)) as SystemMessage;
|
||||
const systemMessageProperty =
|
||||
await this.propertyService.getByKey<SystemMessage>(
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
);
|
||||
|
||||
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
|
||||
systemMessage = systemMessageProperty;
|
||||
@ -121,6 +142,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
activitiesCount,
|
||||
id,
|
||||
permissions,
|
||||
@ -134,7 +156,6 @@ export class UserService {
|
||||
permissions: accessItem.permissions
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
dateOfFirstActivity: firstActivity?.date ?? new Date(),
|
||||
settings: {
|
||||
...(Settings.settings as UserSettings),
|
||||
@ -161,26 +182,26 @@ export class UserService {
|
||||
const {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
Analytics,
|
||||
accounts,
|
||||
analytics,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
provider,
|
||||
role,
|
||||
Settings,
|
||||
Subscription,
|
||||
subscriptions,
|
||||
thirdPartyId,
|
||||
updatedAt
|
||||
} = await this.prismaService.user.findUnique({
|
||||
include: {
|
||||
Access: true,
|
||||
Account: {
|
||||
include: { Platform: true }
|
||||
accounts: {
|
||||
include: { platform: true }
|
||||
},
|
||||
Analytics: true,
|
||||
analytics: true,
|
||||
Settings: true,
|
||||
Subscription: true
|
||||
subscriptions: true
|
||||
},
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
@ -188,7 +209,7 @@ export class UserService {
|
||||
const user: UserWithSettings = {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
accounts,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -197,9 +218,9 @@ export class UserService {
|
||||
Settings: Settings as UserWithSettings['Settings'],
|
||||
thirdPartyId,
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount,
|
||||
activityCount: analytics?.activityCount,
|
||||
dataProviderGhostfolioDailyRequests:
|
||||
Analytics?.dataProviderGhostfolioDailyRequests
|
||||
analytics?.dataProviderGhostfolioDailyRequests
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
@ -226,6 +247,12 @@ export class UserService {
|
||||
? 'max'
|
||||
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||
|
||||
// Set default value for performance calculation type
|
||||
if (!(user.Settings.settings as UserSettings)?.performanceCalculationType) {
|
||||
(user.Settings.settings as UserSettings).performanceCalculationType =
|
||||
PerformanceCalculationType.ROAI;
|
||||
}
|
||||
|
||||
// Set default value for view mode
|
||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||
@ -233,28 +260,41 @@ export class UserService {
|
||||
|
||||
(user.Settings.settings as UserSettings).xRayRules = {
|
||||
AccountClusterRiskCurrentInvestment:
|
||||
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
|
||||
user.Settings.settings
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskCurrentInvestment:
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
@ -271,10 +311,14 @@ export class UserService {
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
EmergencyFundSetup: new EmergencyFundSetup(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
@ -311,6 +355,11 @@ export class UserService {
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (user.provider === 'ANONYMOUS') {
|
||||
currentPermissions.push(permissions.deleteOwnUser);
|
||||
currentPermissions.push(permissions.updateOwnAccessToken);
|
||||
}
|
||||
|
||||
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||
// currentPermissions = without(
|
||||
// currentPermissions,
|
||||
@ -319,9 +368,9 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.subscription = this.subscriptionService.getSubscription({
|
||||
createdAt: user.createdAt,
|
||||
subscriptions: Subscription
|
||||
user.subscription = await this.subscriptionService.getSubscription({
|
||||
subscriptions,
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
|
||||
if (user.subscription?.type === 'Basic') {
|
||||
@ -329,21 +378,23 @@ export class UserService {
|
||||
new Date(),
|
||||
user.createdAt
|
||||
);
|
||||
let frequency = 10;
|
||||
let frequency = 7;
|
||||
|
||||
if (daysSinceRegistration > 365) {
|
||||
if (daysSinceRegistration > 720) {
|
||||
frequency = 1;
|
||||
} else if (daysSinceRegistration > 360) {
|
||||
frequency = 2;
|
||||
} else if (daysSinceRegistration > 180) {
|
||||
frequency = 3;
|
||||
} else if (daysSinceRegistration > 60) {
|
||||
frequency = 4;
|
||||
} else if (daysSinceRegistration > 30) {
|
||||
frequency = 6;
|
||||
frequency = 5;
|
||||
} else if (daysSinceRegistration > 15) {
|
||||
frequency = 8;
|
||||
frequency = 6;
|
||||
}
|
||||
|
||||
if (Analytics?.activityCount % frequency === 1) {
|
||||
if (analytics?.activityCount % frequency === 1) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
}
|
||||
|
||||
@ -353,6 +404,7 @@ export class UserService {
|
||||
permissions.createAccess,
|
||||
permissions.createMarketDataOfOwnAssetProfile,
|
||||
permissions.createOwnTag,
|
||||
permissions.createWatchlistItem,
|
||||
permissions.readAiPrompt,
|
||||
permissions.readMarketDataOfOwnAssetProfile,
|
||||
permissions.updateMarketDataOfOwnAssetProfile
|
||||
@ -364,14 +416,26 @@ export class UserService {
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.createApiKey);
|
||||
currentPermissions.push(permissions.enableDataProviderGhostfolio);
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
if (!hasRole(user, Role.DEMO)) {
|
||||
currentPermissions.push(permissions.createApiKey);
|
||||
currentPermissions.push(permissions.enableDataProviderGhostfolio);
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
}
|
||||
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.deleteOwnUser
|
||||
);
|
||||
|
||||
// Reset offer
|
||||
user.subscription.offer.coupon = undefined;
|
||||
user.subscription.offer.couponId = undefined;
|
||||
user.subscription.offer.durationExtension = undefined;
|
||||
user.subscription.offer.label = undefined;
|
||||
}
|
||||
|
||||
if (hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.syncDemoUserAccount);
|
||||
}
|
||||
}
|
||||
|
||||
@ -380,9 +444,9 @@ export class UserService {
|
||||
currentPermissions.push(permissions.toggleReadOnlyMode);
|
||||
}
|
||||
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
const isReadOnlyMode = await this.propertyService.getByKey<boolean>(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
);
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
currentPermissions = currentPermissions.filter((permission) => {
|
||||
@ -395,11 +459,11 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment.production && role === 'ADMIN') {
|
||||
if (!environment.production && hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.impersonateAllUsers);
|
||||
}
|
||||
|
||||
user.Account = sortBy(user.Account, ({ name }) => {
|
||||
user.accounts = sortBy(user.accounts, ({ name }) => {
|
||||
return name.toLowerCase();
|
||||
});
|
||||
user.permissions = currentPermissions.sort();
|
||||
@ -433,10 +497,10 @@ export class UserService {
|
||||
data.provider = 'ANONYMOUS';
|
||||
}
|
||||
|
||||
let user = await this.prismaService.user.create({
|
||||
const user = await this.prismaService.user.create({
|
||||
data: {
|
||||
...data,
|
||||
Account: {
|
||||
accounts: {
|
||||
create: {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
name: this.i18nService.getTranslation({
|
||||
@ -458,20 +522,17 @@ export class UserService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
await this.prismaService.analytics.create({
|
||||
data: {
|
||||
User: { connect: { id: user.id } }
|
||||
user: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(user.id, getRandomString(10));
|
||||
const { accessToken, hashedAccessToken } = this.generateAccessToken({
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const hashedAccessToken = this.createAccessToken(
|
||||
accessToken,
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
);
|
||||
|
||||
user = await this.prismaService.user.update({
|
||||
await this.prismaService.user.update({
|
||||
data: { accessToken: hashedAccessToken },
|
||||
where: { id: user.id }
|
||||
});
|
||||
@ -556,7 +617,7 @@ export class UserService {
|
||||
const { settings } = await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
settings: userSettings as unknown as Prisma.JsonObject,
|
||||
User: {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,7 @@
|
||||
"name": "Ghostfolio",
|
||||
"orientation": "portrait",
|
||||
"short_name": "Ghostfolio",
|
||||
"start_url": "/en/",
|
||||
"start_url": "/${languageCode}/",
|
||||
"theme_color": "#FFFFFF",
|
||||
"url": "https://ghostfol.io"
|
||||
"url": "${rootUrl}"
|
||||
}
|
@ -4,593 +4,6 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/features</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/maerkte</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/preise</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/registrierung</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/license</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/11/black-week-2023</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/saas</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/self-hosting</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/features</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/markets</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/pricing</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/register</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/glossary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/guides</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/markets</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/funcionalidades</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/mercados</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/precios</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/recursos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/recursos/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/registro</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/enregistrement</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/marches</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/prix</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/ressources/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/funzionalita</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/iscrizione</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/mercati</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/prezzi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/markten</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/open</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/registratie</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/cennik</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/funkcje</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/o-ghostfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/open</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/rynki</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/zarejestruj</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/blog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/open</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/precos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/recursos/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/registo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/tr</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/uk</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/zh</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
${publicRoutes}
|
||||
${personalFinanceTools}
|
||||
</urlset>
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config';
|
||||
|
||||
export const environment = {
|
||||
production: true,
|
||||
rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`,
|
||||
version: `${require('../../../../package.json').version}`
|
||||
};
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { DEFAULT_HOST } from '@ghostfolio/common/config';
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
rootUrl: `https://${DEFAULT_HOST}:4200`,
|
||||
version: 'dev'
|
||||
};
|
||||
|
@ -1515,11 +1515,11 @@ describe('redactAttributes', () => {
|
||||
}
|
||||
},
|
||||
summary: {
|
||||
activityCount: 29,
|
||||
annualizedPerformancePercent: 0.16690880197786,
|
||||
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
|
||||
cash: null,
|
||||
excludedAccountsAndActivities: null,
|
||||
firstOrderDate: '2017-01-02T23:00:00.000Z',
|
||||
netPerformance: null,
|
||||
netPerformancePercentage: 2.3039314216696174,
|
||||
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
|
||||
@ -1539,7 +1539,6 @@ describe('redactAttributes', () => {
|
||||
interest: null,
|
||||
items: null,
|
||||
liabilities: null,
|
||||
ordersCount: 29,
|
||||
totalInvestment: null,
|
||||
totalValueInBaseCurrency: null,
|
||||
currentNetWorth: null
|
||||
@ -3019,11 +3018,11 @@ describe('redactAttributes', () => {
|
||||
}
|
||||
},
|
||||
summary: {
|
||||
activityCount: 29,
|
||||
annualizedPerformancePercent: 0.16690880197786,
|
||||
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
|
||||
cash: null,
|
||||
excludedAccountsAndActivities: null,
|
||||
firstOrderDate: '2017-01-02T23:00:00.000Z',
|
||||
netPerformance: null,
|
||||
netPerformancePercentage: 2.3039314216696174,
|
||||
netPerformancePercentageWithCurrencyEffect: 2.3589806001456606,
|
||||
@ -3043,7 +3042,6 @@ describe('redactAttributes', () => {
|
||||
interest: null,
|
||||
items: null,
|
||||
liabilities: null,
|
||||
ordersCount: 29,
|
||||
totalInvestment: null,
|
||||
totalValueInBaseCurrency: null,
|
||||
currentNetWorth: null
|
||||
|
@ -26,7 +26,7 @@ export class TransformDataSourceInRequestInterceptor<T>
|
||||
const request = http.getRequest();
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (request.body.activities) {
|
||||
if (request.body?.activities) {
|
||||
request.body.activities = request.body.activities.map((activity) => {
|
||||
if (DataSource[activity.dataSource]) {
|
||||
return activity;
|
||||
@ -43,7 +43,16 @@ export class TransformDataSourceInRequestInterceptor<T>
|
||||
const dataSourceValue = request[type]?.dataSource;
|
||||
|
||||
if (dataSourceValue && !DataSource[dataSourceValue]) {
|
||||
request[type].dataSource = decodeDataSource(dataSourceValue);
|
||||
// In Express 5, request.query is read-only, so request[type].dataSource cannot be directly modified
|
||||
Object.defineProperty(request, type, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {
|
||||
...request[type],
|
||||
dataSource: decodeDataSource(dataSourceValue)
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,10 @@
|
||||
import {
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
STORYBOOK_PATH,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import {
|
||||
Logger,
|
||||
LogLevel,
|
||||
@ -7,11 +14,11 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
|
||||
|
||||
async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
@ -37,7 +44,15 @@ async function bootstrap() {
|
||||
defaultVersion: '1',
|
||||
type: VersioningType.URI
|
||||
});
|
||||
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'sitemap.xml',
|
||||
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
|
||||
// Exclude language-specific routes with an optional wildcard
|
||||
return `/${languageCode}{/*wildcard}`;
|
||||
})
|
||||
]
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
@ -50,26 +65,28 @@ async function bootstrap() {
|
||||
app.useBodyParser('json', { limit: '10mb' });
|
||||
|
||||
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
|
||||
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||
}
|
||||
},
|
||||
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||
})
|
||||
);
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith(STORYBOOK_PATH)) {
|
||||
next();
|
||||
} else {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
|
||||
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||
}
|
||||
},
|
||||
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||
})(req, res, next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.use(HtmlTemplateMiddleware);
|
||||
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
|
||||
const PORT = configService.get<number>('PORT') || DEFAULT_PORT;
|
||||
|
||||
await app.listen(PORT, HOST, () => {
|
||||
logLogo();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user