Compare commits
543 Commits
Author | SHA1 | Date | |
---|---|---|---|
e79d607ab8 | |||
5f7d083f7c | |||
15857118fe | |||
ff91ed21df | |||
9241c04d5a | |||
5d4e2fba8c | |||
6c57609db8 | |||
b31bbbe2d1 | |||
7d308917dd | |||
4e7d93db13 | |||
45340b581f | |||
6f8fe45fc2 | |||
34d9ceb009 | |||
2b97bbd05d | |||
3bf7ac76a0 | |||
71892e67b2 | |||
6e2885ed20 | |||
07c0e5a612 | |||
719bbe156e | |||
b51255a543 | |||
50dbbf0569 | |||
ca2e748c56 | |||
5c480109d5 | |||
6152ff4b44 | |||
c6641fde36 | |||
4ae7e9fcbe | |||
c10ae431a2 | |||
883e30e451 | |||
f1f4f6247d | |||
82fe1de1a7 | |||
371c999fbc | |||
26b9660e11 | |||
ca7717f9c5 | |||
6f3cce1c5f | |||
efdc9b387f | |||
d7b579e3e8 | |||
b8533050b0 | |||
1b81409b35 | |||
8cd6c34ed8 | |||
0c68474802 | |||
34997f91db | |||
084467ee9a | |||
af47889d65 | |||
51203ec96e | |||
a2277dea2c | |||
debd233c32 | |||
f1eeee0525 | |||
5ffc39c32f | |||
a668a66e84 | |||
0581b8b9ec | |||
63a61fb492 | |||
5788c6474e | |||
5529fdc0ee | |||
88a9b518f6 | |||
98de2355c4 | |||
b41eb60348 | |||
0edebe30e1 | |||
e3abe4feee | |||
50391e199a | |||
a33f8d5bed | |||
636be8441e | |||
654dc2ba32 | |||
458ef169f4 | |||
5bb01bb03c | |||
43e9528d8c | |||
522c54c9b4 | |||
0004ced4e1 | |||
274c60e961 | |||
754e98099c | |||
87bf8df1c3 | |||
3f7d6b25c7 | |||
8a062e03ab | |||
a70f45cbf3 | |||
f268264c46 | |||
bbe5d70720 | |||
f1d2a52cba | |||
87cc887865 | |||
61ecd15f1d | |||
eb853f05ae | |||
6285417903 | |||
ca674a654e | |||
2729c5651f | |||
7e28e42995 | |||
e21563d903 | |||
3ede69650c | |||
c289793c6d | |||
a90c067da0 | |||
38c2baf943 | |||
82c78cad6b | |||
bffe6060bd | |||
841bd5c33f | |||
3b895afc9e | |||
00c2ede85e | |||
8420cb830c | |||
a0ddd1f9b9 | |||
40d93066ff | |||
671e4e316b | |||
473136e9aa | |||
9a3db91982 | |||
d23cb5f190 | |||
7a364472c8 | |||
59c064e3c8 | |||
e792924606 | |||
d32dd5e860 | |||
bb86f85203 | |||
0bca8897d6 | |||
ba73f6de2e | |||
eb75be8535 | |||
6d2a897366 | |||
d8bfb23f20 | |||
d9d71e7827 | |||
b642ce08e5 | |||
bc8d8309d4 | |||
1f2f9f22f2 | |||
7a3237f1ff | |||
07661d9262 | |||
77358eed65 | |||
c641c28b12 | |||
c54392b7bb | |||
f3a8822a77 | |||
f1dc075c36 | |||
144d831954 | |||
c37ad9bad4 | |||
4ab3f81384 | |||
b932bac9aa | |||
bcdd873222 | |||
25b3de5828 | |||
40b454d2f3 | |||
5596e5f03b | |||
66992ef915 | |||
7f67430685 | |||
8a49a04324 | |||
5d7c19b0ed | |||
cde74b6c62 | |||
633c65e33c | |||
d1617f2d87 | |||
68e558f198 | |||
12ca01c862 | |||
2115745471 | |||
2cabd21315 | |||
3615e2f057 | |||
d3679d41b3 | |||
f2d431a6b8 | |||
2bc8bebfb8 | |||
5b20ba3382 | |||
15cc294581 | |||
b060b81204 | |||
a8d557eb1b | |||
6ae3a47b54 | |||
88c19eb45e | |||
7728706bc8 | |||
2e9d40c201 | |||
c002e37285 | |||
6be38a1c19 | |||
a3178fb213 | |||
e7158f6e16 | |||
dbea0456bc | |||
fefee11301 | |||
40836b745b | |||
07eabac059 | |||
48b412cfb8 | |||
b62488628c | |||
982c71c728 | |||
5aa16a3779 | |||
93de25e5b6 | |||
9acdb41aa2 | |||
ffbdfb86ec | |||
be7f6bb657 | |||
6f7cbc93b9 | |||
0b5c71130d | |||
0578c645d1 | |||
67ae86763e | |||
266c0a9a2c | |||
a3cdb23776 | |||
e1371a8d2b | |||
448cea0b69 | |||
ad42c0bf28 | |||
f50670c7fe | |||
c0029d3b1d | |||
2518a8fd9d | |||
572dcf075a | |||
29cb83d469 | |||
cac73ac111 | |||
02cf4295a9 | |||
78b3328bf7 | |||
e0d6d9e8ca | |||
54310f2214 | |||
1fec49fbc2 | |||
d00489b547 | |||
2985dd67c5 | |||
5eba764c04 | |||
cc0ce18627 | |||
b758654158 | |||
d5d40c0ea1 | |||
fd294d4d2b | |||
e82cf2e7d0 | |||
446c7cb517 | |||
e921ed7f52 | |||
865402be3a | |||
6eb659d7e6 | |||
37430b7bdc | |||
ef9d77312e | |||
ccaf06360a | |||
f83e75df44 | |||
00a2b60eb5 | |||
fcbf2f1645 | |||
460266a501 | |||
9fe90273c7 | |||
4078229fe6 | |||
609c03f174 | |||
e7d4641d13 | |||
cc1d9811e0 | |||
35450ac004 | |||
9c18f48a32 | |||
87529490c3 | |||
893e76f83f | |||
06ba7a4b1b | |||
c68d113d27 | |||
69e3bee52c | |||
cea569c987 | |||
2a38a16f6b | |||
0f9455cf02 | |||
d4afa03505 | |||
c9237146e2 | |||
faad65b6f3 | |||
e459c72100 | |||
a8add30125 | |||
b535aee91d | |||
4434d0315f | |||
8b10695353 | |||
e82dcc8ace | |||
6dcb0d8583 | |||
40b6777814 | |||
25deba16df | |||
be93ca8968 | |||
0436cc6487 | |||
857708dc4d | |||
1ca4f885b0 | |||
c9368c5cf2 | |||
29423efea3 | |||
f3ee99fb2b | |||
3df8810412 | |||
b8ca88c6df | |||
2c068c412d | |||
9fdbd22cb5 | |||
8f5f4c5875 | |||
50fb82a6e6 | |||
2c10cd7edf | |||
bbde86c66e | |||
73c0843d51 | |||
04fc2cd3e1 | |||
b39c97ab9f | |||
1dd5e9c787 | |||
a9985b65b8 | |||
0a35d5f236 | |||
09ce8b1cd0 | |||
a5ed49fe4c | |||
5c23ece62c | |||
4e9e3f7b6b | |||
5fc84a06cc | |||
12186e1c6c | |||
f2803aecbc | |||
5ba5b86d5f | |||
6167f105fe | |||
8d5f2fd91d | |||
4ac661fb94 | |||
e763bfb2e2 | |||
88c7e34cc3 | |||
0ee632470e | |||
c918deeb1c | |||
1877b31f00 | |||
00895b7bb1 | |||
bff60ddbe0 | |||
d46de0a15e | |||
7b45a8b3fc | |||
693791d113 | |||
1b2d2a9860 | |||
bde8be1385 | |||
74ca058364 | |||
ba3cf82c6e | |||
217bb6aa5a | |||
440dc470fa | |||
165ca94f5b | |||
c418e75139 | |||
76bf839010 | |||
3bdc4c9b4a | |||
005890d785 | |||
256c020e88 | |||
5fa3388609 | |||
be801b481e | |||
a72e98f73c | |||
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f | |||
67dbef3b7a | |||
0e94112dc7 | |||
b22edff16b | |||
ffb7cbff50 | |||
25424ad280 | |||
a768902b00 | |||
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 | |||
5db5d5e79a | |||
12aac101bd | |||
3a66ccdebe | |||
6a722d1bb7 | |||
7c9407d5dc | |||
8abb517ac6 | |||
dec1d89c5c | |||
24e9ecc3e2 | |||
4a1e05b8cd | |||
39d1a85267 | |||
7cb86de7af | |||
aa078588e8 | |||
fcef0a72d5 | |||
29987d3e2f | |||
6284b4dfe8 | |||
00342ca1f7 | |||
234c4fd511 | |||
669f1fb60c | |||
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 | |||
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 | |||
60b2115e3b | |||
e7956943ba | |||
f66edf8de0 | |||
29028a81f5 | |||
c9878c9050 | |||
73ac4b4197 | |||
016634a77f | |||
ea65dc5034 | |||
84db54babd | |||
653c9c62a8 | |||
74278073b3 | |||
0375b938a2 | |||
32df7620d9 | |||
8492a8fed0 | |||
30e561c06f | |||
7243090c0e | |||
7ae49eb839 | |||
bf816c3b89 | |||
20f9225daa | |||
b6101c6375 | |||
e1022846b9 | |||
9ba79f6721 | |||
0ac97bd112 | |||
827270704a | |||
8634463597 | |||
3905782ad6 | |||
5db984ffef | |||
fb3cd4b689 | |||
3b5a34f6f3 | |||
22b43b5bfc | |||
6c66033eb4 |
25
.env.dev
Normal file
25
.env.dev
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||||
|
|
||||||
|
# CACHE
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
|
# POSTGRES
|
||||||
|
POSTGRES_DB=ghostfolio-db
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
|
||||||
|
# DEVELOPMENT
|
||||||
|
|
||||||
|
# Nx 18 enables using plugins to infer targets by default
|
||||||
|
# This is disabled for existing workspaces to maintain compatibility
|
||||||
|
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||||
|
NX_ADD_PLUGINS=false
|
||||||
|
|
||||||
|
NX_NATIVE_COMMAND_RUNNER=false
|
@ -1,4 +1,4 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
COMPOSE_PROJECT_NAME=ghostfolio
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
|
|||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
**Important Notice**
|
||||||
|
|
||||||
|
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
|
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
||||||
|
|
||||||
|
Thank you for your understanding and cooperation!
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- Cloud or Self-hosted
|
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Cloud or Self-hosted
|
||||||
|
- Experimental Features enabled or disabled
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
|
7
.github/workflows/build-code.yml
vendored
7
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -13,12 +16,12 @@ jobs:
|
|||||||
- 18
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node_version }}
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
3
.github/workflows/docker-image.yml
vendored
3
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
@ -21,6 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghostfolio/ghostfolio
|
images: ghostfolio/ghostfolio
|
||||||
tags: |
|
tags: |
|
||||||
|
type=semver,pattern={{major}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
|||||||
/.angular/cache
|
/.angular/cache
|
||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
|
.nx/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
/.nx/cache
|
||||||
|
/apps/client/src/polyfills.ts
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
21
.prettierrc
21
.prettierrc
@ -9,7 +9,26 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"plugins": ["prettier-plugin-organize-attributes"],
|
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.ts",
|
||||||
|
"options": {
|
||||||
|
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-attributes",
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
1015
CHANGELOG.md
1015
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,17 @@
|
|||||||
# Ghostfolio Development Guide
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Experimental Features
|
||||||
|
|
||||||
|
New functionality can be enabled using a feature flag switch from the user settings.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Remove permission in `UserService` using `without()`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
### Rebase
|
### Rebase
|
||||||
@ -8,13 +20,19 @@
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
### Angular
|
||||||
|
|
||||||
|
#### Upgrade (minor versions)
|
||||||
|
|
||||||
|
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||||
|
|
||||||
### Nx
|
### Nx
|
||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `yarn nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
1. Run `yarn nx migrate --run-migrations`
|
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
@ -13,8 +13,8 @@ COPY ./.yarnrc .yarnrc
|
|||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
git \
|
|
||||||
g++ \
|
g++ \
|
||||||
|
git \
|
||||||
make \
|
make \
|
||||||
openssl \
|
openssl \
|
||||||
python3 \
|
python3 \
|
||||||
@ -52,10 +52,12 @@ RUN yarn database:generate-typings
|
|||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:18-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
|
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:production" ]
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
85
README.md
85
README.md
@ -7,7 +7,7 @@
|
|||||||
**Open Source Wealth Management Software**
|
**Open Source Wealth Management Software**
|
||||||
|
|
||||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/ghostfolio)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing)
|
||||||
@ -49,7 +49,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -87,19 +87,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
||||||
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -115,7 +119,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
@ -123,8 +127,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
|||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -135,12 +139,12 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -150,13 +154,14 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
|||||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
|
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open http://localhost:4200/en in your browser
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
@ -165,7 +170,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
|||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
@ -201,7 +206,7 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
||||||
|
|
||||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
@ -230,18 +235,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| fee | number | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| quantity | number | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| unitPrice | number | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -272,12 +277,16 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -13,7 +13,6 @@ export default {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.js'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -7,14 +7,16 @@
|
|||||||
"generators": {},
|
"generators": {},
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@nrwl/webpack:webpack",
|
"executor": "@nx/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
"compiler": "tsc",
|
||||||
|
"deleteOutputPath": false,
|
||||||
"main": "apps/api/src/main.ts",
|
"main": "apps/api/src/main.ts",
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
"outputPath": "dist/apps/api",
|
||||||
"assets": ["apps/api/src/assets"],
|
"sourceMap": true,
|
||||||
"target": "node",
|
"target": "node",
|
||||||
"compiler": "tsc"
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@ -32,6 +34,26 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
|
"copy-assets": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "shx rm -rf dist/apps/api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parallel": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
@ -39,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nrwl/linter:eslint",
|
"executor": "@nx/eslint:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
}
|
}
|
||||||
@ -47,8 +69,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@nx/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.ts",
|
"jestConfig": "apps/api/jest.config.ts"
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
},
|
||||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
|
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 { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -17,7 +21,6 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Access as AccessModel } from '@prisma/client';
|
import { Access as AccessModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccessModule } from './access.module';
|
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { CreateAccessDto } from './create-access.dto';
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@ -25,11 +28,12 @@ import { CreateAccessDto } from './create-access.dto';
|
|||||||
export class AccessController {
|
export class AccessController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAllAccesses(): Promise<Access[]> {
|
public async getAllAccesses(): Promise<Access[]> {
|
||||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||||
include: {
|
include: {
|
||||||
@ -39,32 +43,38 @@ export class AccessController {
|
|||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map(
|
||||||
if (access.GranteeUser) {
|
({ alias, GranteeUser, id, permissions }) => {
|
||||||
|
if (GranteeUser) {
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
id,
|
||||||
|
permissions,
|
||||||
|
grantee: GranteeUser?.id,
|
||||||
|
type: 'PRIVATE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias: access.alias,
|
alias,
|
||||||
grantee: access.GranteeUser?.id,
|
id,
|
||||||
id: access.id,
|
permissions,
|
||||||
type: 'RESTRICTED_VIEW'
|
grantee: 'Public',
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return {
|
|
||||||
alias: access.alias,
|
|
||||||
grantee: 'Public',
|
|
||||||
id: access.id,
|
|
||||||
type: 'PUBLIC'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccess)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccess(
|
public async createAccess(
|
||||||
@Body() data: CreateAccessDto
|
@Body() data: CreateAccessDto
|
||||||
): Promise<AccessModel> {
|
): Promise<AccessModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -72,25 +82,30 @@ export class AccessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
try {
|
||||||
alias: data.alias || undefined,
|
return this.accessService.createAccess({
|
||||||
GranteeUser: data.granteeUserId
|
alias: data.alias || undefined,
|
||||||
? { connect: { id: data.granteeUserId } }
|
GranteeUser: data.granteeUserId
|
||||||
: undefined,
|
? { connect: { id: data.granteeUserId } }
|
||||||
User: { connect: { id: this.request.user.id } }
|
: undefined,
|
||||||
});
|
permissions: data.permissions,
|
||||||
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccess)
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||||
const access = await this.accessService.access({ id });
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (!access || access.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
|
||||||
!access ||
|
|
||||||
access.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
@ -7,7 +9,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [PrismaModule],
|
imports: [ConfigurationModule, PrismaModule],
|
||||||
providers: [AccessService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { AccessPermission } from '@prisma/client';
|
||||||
|
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccessDto {
|
export class CreateAccessDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -6,10 +7,10 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsUUID()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AccessPermission, { each: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
permissions?: AccessPermission[];
|
||||||
type?: 'PUBLIC';
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AccountBalance } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
|
@Controller('account-balance')
|
||||||
|
export class AccountBalanceController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
15
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
15
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AccountBalanceController } from './account-balance.controller';
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AccountBalanceController],
|
||||||
|
exports: [AccountBalanceService],
|
||||||
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
|
providers: [AccountBalanceService]
|
||||||
|
})
|
||||||
|
export class AccountBalanceModule {}
|
92
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
92
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountBalanceService {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async accountBalance(
|
||||||
|
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||||
|
): Promise<AccountBalance | null> {
|
||||||
|
return this.prismaService.accountBalance.findFirst({
|
||||||
|
include: {
|
||||||
|
Account: true
|
||||||
|
},
|
||||||
|
where: accountBalanceWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAccountBalance(
|
||||||
|
data: Prisma.AccountBalanceCreateInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccountBalances({
|
||||||
|
filters,
|
||||||
|
user,
|
||||||
|
withExcludedAccounts
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
user: UserWithSettings;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
|
}): Promise<AccountBalancesResponse> {
|
||||||
|
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||||
|
|
||||||
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
|
return type === 'ACCOUNT';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accountFilter) {
|
||||||
|
where.accountId = accountFilter.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.Account = { isExcluded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this.prismaService.accountBalance.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
Account: true,
|
||||||
|
date: true,
|
||||||
|
id: true,
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances: balances.map((balance) => {
|
||||||
|
return {
|
||||||
|
...balance,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
balance.value,
|
||||||
|
balance.Account.currency,
|
||||||
|
user.Settings.settings.baseCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,20 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
AccountBalancesResponse,
|
||||||
|
Accounts
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -29,11 +36,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CreateAccountDto } from './create-account.dto';
|
import { CreateAccountDto } from './create-account.dto';
|
||||||
|
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from './update-account.dto';
|
import { UpdateAccountDto } from './update-account.dto';
|
||||||
|
|
||||||
@Controller('account')
|
@Controller('account')
|
||||||
export class AccountController {
|
export class AccountController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -41,17 +50,9 @@ export class AccountController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccount)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await this.accountService.accountWithOrders(
|
const account = await this.accountService.accountWithOrders(
|
||||||
{
|
{
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -62,7 +63,7 @@ export class AccountController {
|
|||||||
{ Order: true }
|
{ Order: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (account?.isDefault || account?.Order.length > 0) {
|
if (!account || account?.Order.length > 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -81,7 +82,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
@ -96,7 +97,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@ -115,20 +116,24 @@ export class AccountController {
|
|||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/balances')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
public async getAccountBalancesById(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalancesResponse> {
|
||||||
|
return this.accountBalanceService.getAccountBalances({
|
||||||
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccount)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
@Body() data: CreateAccountDto
|
@Body() data: CreateAccountDto
|
||||||
): Promise<AccountModel> {
|
): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.platformId) {
|
if (data.platformId) {
|
||||||
const platformId = data.platformId;
|
const platformId = data.platformId;
|
||||||
delete data.platformId;
|
delete data.platformId;
|
||||||
@ -154,18 +159,64 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@HasPermission(permissions.updateAccount)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@Post('transfer-balance')
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
if (
|
public async transferAccountBalance(
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
) {
|
) {
|
||||||
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountFrom = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdFrom;
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountTo = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountFrom || !accountTo) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.NOT_FOUND
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountFrom.id === accountTo.id) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountFrom.balance < balance) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountFrom.id,
|
||||||
|
amount: -balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountTo.id,
|
||||||
|
amount: balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateAccount)
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
const originalAccount = await this.accountService.account({
|
const originalAccount = await this.accountService.account({
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -20,10 +21,8 @@ export class AccountService {
|
|||||||
public async account({
|
public async account({
|
||||||
id_userId
|
id_userId
|
||||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
const { id, userId } = id_userId;
|
|
||||||
|
|
||||||
const [account] = await this.accounts({
|
const [account] = await this.accounts({
|
||||||
where: { id, userId }
|
where: id_userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
@ -109,7 +108,7 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string) {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -218,13 +217,13 @@ export class AccountService {
|
|||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
date,
|
date = new Date(),
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
date: Date;
|
date?: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const { balance, currency: currencyOfAccount } = await this.account({
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -10,10 +10,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountType?: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@ -24,7 +20,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsNumber, IsString } from 'class-validator';
|
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class TransferBalanceDto {
|
export class TransferBalanceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -8,5 +8,6 @@ export class TransferBalanceDto {
|
|||||||
accountIdTo: string;
|
accountIdTo: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
balance: number;
|
balance: number;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -10,10 +10,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountType?: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@ -24,7 +20,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
|
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.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -12,14 +17,14 @@ import {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -27,6 +32,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@ -43,68 +49,38 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly manualService: ManualService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAdminData(): Promise<AdminData> {
|
public async getAdminData(): Promise<AdminData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather')
|
@Post('gather')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gather7Days(): Promise<void> {
|
public async gather7Days(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gather7Days();
|
this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -117,7 +93,8 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -126,21 +103,10 @@ export class AdminController {
|
|||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -153,31 +119,21 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileDataForSymbol(
|
public async gatherProfileDataForSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
await this.dataGatheringService.addJobToQueue({
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -186,53 +142,32 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/:dataSource/:symbol/:dateString')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<MarketData> {
|
): Promise<MarketData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
@ -250,37 +185,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
!hasPermission(
|
filterByAssetSubClasses,
|
||||||
this.request.user.permissions,
|
filterBySearchQuery
|
||||||
permissions.accessAdminControl
|
});
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
|
||||||
|
|
||||||
const filters: Filter[] = [
|
|
||||||
...assetSubClasses.map((assetSubClass) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: assetSubClass,
|
|
||||||
type: 'ASSET_SUB_CLASS'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
return this.adminService.getMarketData({
|
return this.adminService.getMarketData({
|
||||||
filters,
|
filters,
|
||||||
@ -293,46 +212,74 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
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 },
|
||||||
|
@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);
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
return { price };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not parse the current market price');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async update(
|
public async update(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Body() data: UpdateMarketDataDto
|
@Body() data: UpdateMarketDataDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
@ -347,68 +294,39 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('profile-data/:dataSource/:symbol')
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async addProfileData(
|
public async addProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<SymbolProfile | never> {
|
): Promise<SymbolProfile | never> {
|
||||||
if (
|
return this.adminService.addAssetProfile({
|
||||||
!hasPermission(
|
dataSource,
|
||||||
this.request.user.permissions,
|
symbol,
|
||||||
permissions.accessAdminControl
|
currency: this.request.user.Settings.settings.baseCurrency
|
||||||
)
|
});
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Patch('profile-data/:dataSource/:symbol')
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async patchAssetProfileData(
|
public async patchAssetProfileData(
|
||||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<EnhancedSymbolProfile> {
|
): Promise<EnhancedSymbolProfile> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.patchAssetProfileData({
|
return this.adminService.patchAssetProfileData({
|
||||||
...assetProfileData,
|
...assetProfileData,
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -416,24 +334,13 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
if (
|
return this.adminService.putSetting(key, data.value);
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.adminService.putSetting(key, data.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -7,6 +8,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
@ -15,6 +17,7 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -22,8 +22,16 @@ import {
|
|||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Prisma,
|
||||||
|
Property,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@ -41,10 +49,19 @@ export class AdminService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async addAssetProfile({
|
public async addAssetProfile({
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||||
try {
|
try {
|
||||||
|
if (dataSource === 'MANUAL') {
|
||||||
|
return this.symbolProfileService.add({
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
]);
|
||||||
@ -55,7 +72,7 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.symbolProfileService.add(
|
return this.symbolProfileService.add(
|
||||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -85,9 +102,17 @@ export class AdminService {
|
|||||||
return currency !== DEFAULT_CURRENCY;
|
return currency !== DEFAULT_CURRENCY;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
|
const label1 = DEFAULT_CURRENCY;
|
||||||
|
const label2 = currency;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label1: DEFAULT_CURRENCY,
|
label1,
|
||||||
label2: currency,
|
label2,
|
||||||
|
dataSource:
|
||||||
|
DataSource[
|
||||||
|
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||||
|
],
|
||||||
|
symbol: `${label1}${label2}`,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
@ -131,10 +156,14 @@ export class AdminService {
|
|||||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -147,6 +176,15 @@ export class AdminService {
|
|||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
where.OR = [
|
||||||
|
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
@ -173,7 +211,10 @@ export class AdminService {
|
|||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
@ -187,14 +228,17 @@ export class AdminService {
|
|||||||
this.prismaService.symbolProfile.count({ where })
|
this.prismaService.symbolProfile.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let marketData = assetProfiles.map(
|
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||||
({
|
({
|
||||||
_count,
|
_count,
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
@ -213,8 +257,11 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
@ -276,19 +323,49 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
countries,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
|
url
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
const symbolProfileOverrides = {
|
||||||
comment,
|
assetClass: assetClass as AssetClass,
|
||||||
dataSource,
|
assetSubClass: assetSubClass as AssetSubClass,
|
||||||
scraperConfiguration,
|
name: name as string,
|
||||||
symbol,
|
url: url as string
|
||||||
symbolMapping
|
};
|
||||||
});
|
|
||||||
|
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||||
|
{
|
||||||
|
comment,
|
||||||
|
countries,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
...(dataSource === 'MANUAL'
|
||||||
|
? { assetClass, assetSubClass, name, url }
|
||||||
|
: {
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
upsert: {
|
||||||
|
create: symbolProfileOverrides,
|
||||||
|
update: symbolProfileOverrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -341,6 +418,9 @@ export class AdminService {
|
|||||||
symbol,
|
symbol,
|
||||||
assetClass: 'CASH',
|
assetClass: 'CASH',
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
id: undefined,
|
||||||
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -383,13 +463,14 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
role: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30
|
take: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics
|
const engagement = Analytics
|
||||||
@ -399,13 +480,17 @@ export class AdminService {
|
|||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
)
|
)
|
||||||
? this.subscriptionService.getSubscription(Subscription)
|
? this.subscriptionService.getSubscription({
|
||||||
|
createdAt,
|
||||||
|
subscriptions: Subscription
|
||||||
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
role,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics?.country,
|
country: Analytics?.country,
|
||||||
|
@ -1,87 +1,56 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { JobStatus } from 'bull';
|
import { JobStatus } from 'bull';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { QueueService } from './queue.service';
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
@Controller('admin/queue')
|
@Controller('admin/queue')
|
||||||
export class QueueController {
|
export class QueueController {
|
||||||
public constructor(
|
public constructor(private readonly queueService: QueueService) {}
|
||||||
private readonly queueService: QueueService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete('job')
|
@Delete('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job')
|
@Get('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('job/:id')
|
@Delete('job/:id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('job/:id/execute')
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async executeJob(@Param('id') id: string): Promise<void> {
|
||||||
|
return this.queueService.executeJob(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
QUEUE_JOB_STATUS_LIST
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
@ -31,6 +32,10 @@ export class QueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async executeJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.promote();
|
||||||
|
}
|
||||||
|
|
||||||
public async getJobs({
|
public async getJobs({
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
status = QUEUE_JOB_STATUS_LIST
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
@ -53,6 +58,7 @@ export class QueueService {
|
|||||||
finishedOn: job.finishedOn,
|
finishedOn: job.finishedOn,
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
|
opts: job.opts,
|
||||||
stacktrace: job.stacktrace,
|
stacktrace: job.stacktrace,
|
||||||
state: await job.getState(),
|
state: await job.getState(),
|
||||||
timestamp: job.timestamp
|
timestamp: job.timestamp
|
||||||
|
@ -1,18 +1,57 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
countries?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
|
@IsISO4217CurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
scraperConfiguration?: Prisma.InputJsonObject;
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
sectors?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
[dataProvider: string]: string;
|
[dataProvider: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||||
|
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
export class UpdateBulkMarketDataDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => UpdateMarketDataDto)
|
||||||
|
marketData: UpdateMarketDataDto[];
|
||||||
|
}
|
@ -1,6 +1,10 @@
|
|||||||
import { IsNumber } from 'class-validator';
|
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateMarketDataDto {
|
export class UpdateMarketDataDto {
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
SUPPORTED_LANGUAGE_CODES
|
SUPPORTED_LANGUAGE_CODES
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
@ -52,6 +53,7 @@ import { UserModule } from './user/user.module';
|
|||||||
BenchmarkModule,
|
BenchmarkModule,
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
|
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
@ -73,6 +75,7 @@ import { UserModule } from './user/user.module';
|
|||||||
PlatformModule,
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
|
@ -1,40 +1,19 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
|
||||||
Delete,
|
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('auth-device')
|
@Controller('auth-device')
|
||||||
export class AuthDeviceController {
|
export class AuthDeviceController {
|
||||||
public constructor(
|
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||||
private readonly authDeviceService: AuthDeviceService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAuthDevice)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.deleteAuthDevice
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.authDeviceService.deleteAuthDevice({ id });
|
await this.authDeviceService.deleteAuthDevice({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-devic
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -118,13 +120,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-registration-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async generateRegistrationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateRegistrationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
|
@ -5,6 +5,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
|
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
export interface AuthDeviceDialogParams {
|
export interface AuthDeviceDialogParams {
|
||||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
|
@ -3,6 +3,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@ -40,7 +41,7 @@ export class WebAuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get rpID() {
|
get rpID() {
|
||||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
get expectedOrigin() {
|
get expectedOrigin() {
|
||||||
@ -64,7 +65,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateRegistrationOptions(opts);
|
const options = await generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -88,10 +89,16 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedRegistrationResponse;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyRegistrationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,8 +124,8 @@ export class WebAuthService {
|
|||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
counter,
|
counter,
|
||||||
credentialPublicKey,
|
credentialId: Buffer.from(credentialID),
|
||||||
credentialId: credentialID,
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -152,7 +159,7 @@ export class WebAuthService {
|
|||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAuthenticationOptions(opts);
|
const options = await generateAuthenticationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -181,7 +188,6 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedAuthenticationResponse;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAuthenticationResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
@ -189,9 +195,16 @@ export class WebAuthService {
|
|||||||
},
|
},
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = await verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'WebAuthService');
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
@ -5,8 +8,9 @@ import type {
|
|||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -16,6 +20,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -33,21 +38,10 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -71,23 +65,12 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':dataSource/:symbol')
|
@Delete(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteBenchmark(
|
public async deleteBenchmark(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -120,19 +103,26 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const startDate = new Date(startDateString);
|
const { endDate, startDate } = getInterval(
|
||||||
|
dateRange,
|
||||||
|
new Date(startDateString)
|
||||||
|
);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate,
|
||||||
startDate,
|
startDate,
|
||||||
symbol
|
symbol,
|
||||||
|
userCurrency
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,12 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
|||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BenchmarkController } from './benchmark.controller';
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
@ -17,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
@ -9,18 +10,32 @@ import {
|
|||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
calculateBenchmarkTrend,
|
||||||
|
parseDate,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
Benchmark,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkProperty,
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { format } from 'date-fns';
|
import {
|
||||||
import { uniqBy } from 'lodash';
|
differenceInDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
format,
|
||||||
|
isSameDay,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,6 +44,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@ -45,9 +61,34 @@ export class BenchmarkService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||||
BenchmarkResponse['benchmarks']
|
const historicalData = await this.marketDataService.marketDataItems({
|
||||||
> {
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: { gte: subDays(new Date(), 400) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fiftyDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 50
|
||||||
|
});
|
||||||
|
const twoHundredDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 200
|
||||||
|
});
|
||||||
|
|
||||||
|
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({
|
||||||
|
enableSharing = false,
|
||||||
|
useCache = true
|
||||||
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
@ -62,21 +103,38 @@ export class BenchmarkService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||||
|
[];
|
||||||
|
const promisesBenchmarkTrends: Promise<{
|
||||||
|
trend50d: BenchmarkTrend;
|
||||||
|
trend200d: BenchmarkTrend;
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
}),
|
||||||
|
requestTimeout: ms('30 seconds'),
|
||||||
|
useCache: false
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promisesAllTimeHighs.push(
|
||||||
|
this.marketDataService.getMax({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
promisesBenchmarkTrends.push(
|
||||||
|
this.getBenchmarkTrends({ dataSource, symbol })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeHighs = await Promise.all(promises);
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||||
|
Promise.all(promisesAllTimeHighs),
|
||||||
|
Promise.all(promisesBenchmarkTrends)
|
||||||
|
]);
|
||||||
let storeInCache = true;
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
@ -85,9 +143,9 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
if (allTimeHigh && marketPrice) {
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
allTimeHigh,
|
allTimeHigh.marketPrice,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -101,9 +159,12 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh?.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -111,21 +172,31 @@ export class BenchmarkService {
|
|||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
JSON.stringify(benchmarks),
|
JSON.stringify(benchmarks),
|
||||||
ms('4 hours') / 1000
|
ms('2 hours') / 1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles({
|
||||||
|
enableSharing = false
|
||||||
|
} = {}): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(
|
((await this.propertyService.getByKey(
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
)) as BenchmarkProperty[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
)
|
||||||
return symbolProfileId;
|
.filter((benchmark) => {
|
||||||
});
|
if (enableSharing) {
|
||||||
|
return benchmark.enableSharing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles =
|
||||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
@ -144,9 +215,28 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate = new Date(),
|
||||||
startDate,
|
startDate,
|
||||||
symbol
|
symbol,
|
||||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
userCurrency
|
||||||
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
|
startDate: Date;
|
||||||
|
userCurrency: string;
|
||||||
|
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
|
const days = differenceInDays(endDate, startDate) + 1;
|
||||||
|
const dates = eachDayOfInterval(
|
||||||
|
{
|
||||||
|
start: startDate,
|
||||||
|
end: endDate
|
||||||
|
},
|
||||||
|
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
|
||||||
|
).map((date) => {
|
||||||
|
return resetHours(date);
|
||||||
|
});
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -162,50 +252,92 @@ export class BenchmarkService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: {
|
date: {
|
||||||
gte: startDate
|
in: dates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const step = Math.round(
|
const exchangeRates =
|
||||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
startDate,
|
||||||
|
currencies: [currentSymbolItem.currency],
|
||||||
|
targetCurrency: userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeRateAtStartDate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(startDate, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||||
|
return isSameDay(date, startDate);
|
||||||
|
})?.marketPrice;
|
||||||
|
|
||||||
|
if (!marketPriceAtStartDate) {
|
||||||
|
Logger.error(
|
||||||
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||||
|
startDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'BenchmarkService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketData };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let marketDataItem of marketDataItems) {
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(marketDataItem.date, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice * exchangeRateFactor
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includesEndDate = isSameDay(
|
||||||
|
parseDate(last(marketData).date),
|
||||||
|
endDate
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||||
const response = {
|
const exchangeRate =
|
||||||
marketData: [
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
...marketDataItems
|
format(endDate, DATE_FORMAT)
|
||||||
.filter((marketDataItem, index) => {
|
];
|
||||||
return index % step === 0;
|
|
||||||
})
|
|
||||||
.map((marketDataItem) => {
|
|
||||||
return {
|
|
||||||
date: format(marketDataItem.date, DATE_FORMAT),
|
|
||||||
value:
|
|
||||||
marketPriceAtStartDate === 0
|
|
||||||
? 0
|
|
||||||
: this.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
marketDataItem.marketPrice
|
|
||||||
) * 100
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice) {
|
const exchangeRateFactor =
|
||||||
response.marketData.push({
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
date: format(new Date(), DATE_FORMAT),
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(endDate, DATE_FORMAT),
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
currentSymbolItem.marketPrice
|
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||||
) * 100
|
) * 100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return {
|
||||||
|
marketData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
@ -282,7 +414,15 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
private getMarketCondition(
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
aPerformanceInPercent: number
|
||||||
|
): Benchmark['marketCondition'] {
|
||||||
|
if (aPerformanceInPercent === 0) {
|
||||||
|
return 'ALL_TIME_HIGH';
|
||||||
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
|
return 'BEAR_MARKET';
|
||||||
|
} else {
|
||||||
|
return 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
apps/api/src/app/cache/cache.controller.ts
vendored
36
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,19 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
|
||||||
HttpException,
|
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
Inject,
|
|
||||||
Post,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||||
private readonly redisCacheService: RedisCacheService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.redisCacheService.reset();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/app/cache/cache.module.ts
vendored
1
apps/api/src/app/cache/cache.module.ts
vendored
@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -19,7 +21,7 @@ export class ExchangeRateController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':symbol/:dateString')
|
@Get(':symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getExchangeRate(
|
public async getExchangeRate(
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateController } from './exchange-rate.controller';
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -9,17 +12,29 @@ import { ExportService } from './export.service';
|
|||||||
@Controller('export')
|
@Controller('export')
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async export(
|
public async export(
|
||||||
@Query('activityIds') activityIds?: string[]
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('activityIds') activityIds?: string[],
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
return this.exportService.export({
|
return this.exportService.export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
filters,
|
||||||
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
@ -12,6 +14,7 @@ import { ExportService } from './export.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AccountModule,
|
AccountModule,
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -13,9 +14,13 @@ export class ExportService {
|
|||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
filters,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
|
filters?: Filter[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = (
|
const accounts = (
|
||||||
@ -39,10 +44,14 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let activities = await this.orderService.orders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
filters,
|
||||||
orderBy: { date: 'desc' },
|
userCurrency,
|
||||||
where: { userId }
|
userId,
|
||||||
|
includeDrafts: true,
|
||||||
|
sortColumn: 'date',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activityIds) {
|
if (activityIds) {
|
||||||
@ -86,7 +95,10 @@ export class ExportService {
|
|||||||
: SymbolProfile.symbol
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
user: {
|
||||||
|
settings: { currency: userCurrency }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
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.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -34,19 +37,18 @@ export class ImportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRunParam = 'false'
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
|
const isDryRun = isDryRunParam === 'true';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.createAccount
|
|
||||||
) ||
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -65,16 +67,13 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activities = await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
isDryRun,
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
userCurrency,
|
|
||||||
accountsDto: importData.accounts ?? [],
|
accountsDto: importData.accounts ?? [],
|
||||||
activitiesDto: importData.activities,
|
activitiesDto: importData.activities,
|
||||||
userId: this.request.user.id
|
user: this.request.user
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
@ -92,7 +91,7 @@ export class ImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends/:dataSource/:symbol')
|
@Get('dividends/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async gatherDividends(
|
public async gatherDividends(
|
||||||
|
@ -10,6 +10,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
|
@ -8,10 +8,15 @@ import {
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
@ -20,12 +25,14 @@ import {
|
|||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount
|
OrderWithAccount,
|
||||||
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@ -33,6 +40,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
@ -81,11 +89,13 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
const date = parseDate(dateString);
|
||||||
const isDuplicate = orders.some((activity) => {
|
const isDuplicate = orders.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === Account?.id &&
|
||||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
isSameDay(activity.date, parseDate(dateString)) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||||
activity.type === 'DIVIDEND' &&
|
activity.type === 'DIVIDEND' &&
|
||||||
@ -99,19 +109,20 @@ export class ImportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
Account,
|
Account,
|
||||||
|
date,
|
||||||
error,
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
value,
|
value,
|
||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
|
currency: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
date: parseDate(dateString),
|
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
SymbolProfile: assetProfile,
|
||||||
symbolProfileId: assetProfile.id,
|
symbolProfileId: assetProfile.id,
|
||||||
type: 'DIVIDEND',
|
type: 'DIVIDEND',
|
||||||
unitPrice: marketPrice,
|
unitPrice: marketPrice,
|
||||||
@ -134,17 +145,16 @@ export class ImportService {
|
|||||||
activitiesDto,
|
activitiesDto,
|
||||||
isDryRun = false,
|
isDryRun = false,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
userCurrency,
|
user
|
||||||
userId
|
|
||||||
}: {
|
}: {
|
||||||
accountsDto: Partial<CreateAccountDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
isDryRun?: boolean;
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
userCurrency: string;
|
user: UserWithSettings;
|
||||||
userId: string;
|
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
|
const userCurrency = user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
if (!isDryRun && accountsDto?.length) {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
@ -167,7 +177,7 @@ export class ImportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If there is no account or if the account belongs to a different user then create a new account
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||||
let oldAccountId: string;
|
let oldAccountId: string;
|
||||||
const platformId = account.platformId;
|
const platformId = account.platformId;
|
||||||
|
|
||||||
@ -180,7 +190,7 @@ export class ImportService {
|
|||||||
|
|
||||||
let accountObject: Prisma.AccountCreateInput = {
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
...account,
|
...account,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: user.id } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -196,7 +206,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const newAccount = await this.accountService.createAccount(
|
const newAccount = await this.accountService.createAccount(
|
||||||
accountObject,
|
accountObject,
|
||||||
userId
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store the new to old account ID mappings for updating activities
|
// Store the new to old account ID mappings for updating activities
|
||||||
@ -227,15 +237,17 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport
|
maxActivitiesToImport,
|
||||||
|
user
|
||||||
});
|
});
|
||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
userId
|
userCurrency,
|
||||||
|
userId: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(user.id)).map(
|
||||||
({ id, name }) => {
|
({ id, name }) => {
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
@ -254,6 +266,7 @@ export class ImportService {
|
|||||||
{
|
{
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
error,
|
error,
|
||||||
fee,
|
fee,
|
||||||
@ -278,8 +291,10 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -332,12 +347,12 @@ export class ImportService {
|
|||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
userId,
|
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -348,8 +363,10 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -359,11 +376,13 @@ export class ImportService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
url,
|
url,
|
||||||
|
currency: assetProfile.currency,
|
||||||
comment: assetProfile.comment
|
comment: assetProfile.comment
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
|
userId: user.id
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -377,14 +396,13 @@ export class ImportService {
|
|||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
userId,
|
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol,
|
||||||
|
currency: assetProfile.currency
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -395,7 +413,8 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateAccountBalance: false,
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: user.id } },
|
||||||
|
userId: user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,14 +426,14 @@ export class ImportService {
|
|||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@ -433,15 +452,16 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbols(
|
this.dataGatheringService.gatherSymbols({
|
||||||
uniqueActivities.map(({ date, SymbolProfile }) => {
|
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
@ -449,15 +469,18 @@ export class ImportService {
|
|||||||
|
|
||||||
private async extendActivitiesWithErrors({
|
private async extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Partial<Activity>[]> {
|
}): Promise<Partial<Activity>[]> {
|
||||||
const existingActivities = await this.orderService.orders({
|
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
userCurrency,
|
||||||
orderBy: { date: 'desc' },
|
userId,
|
||||||
where: { userId }
|
includeDrafts: true,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return activitiesDto.map(
|
return activitiesDto.map(
|
||||||
@ -473,12 +496,13 @@ export class ImportService {
|
|||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
const date = parseISO(dateString);
|
||||||
const isDuplicate = existingActivities.some((activity) => {
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === accountId &&
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(activity.date, date) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.fee === fee &&
|
activity.fee === fee &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === symbol &&
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
@ -504,19 +528,14 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: null,
|
activitiesCount: undefined,
|
||||||
assetSubClass: null,
|
assetClass: undefined,
|
||||||
comment: null,
|
assetSubClass: undefined,
|
||||||
countries: null,
|
countries: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
sectors: undefined,
|
||||||
name: null,
|
updatedAt: undefined
|
||||||
scraperConfiguration: null,
|
|
||||||
sectors: null,
|
|
||||||
symbolMapping: null,
|
|
||||||
updatedAt: undefined,
|
|
||||||
url: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -535,10 +554,12 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport
|
maxActivitiesToImport,
|
||||||
|
user
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
|
user: UserWithSettings;
|
||||||
}) {
|
}) {
|
||||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
@ -548,40 +569,53 @@ export class ImportService {
|
|||||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const uniqueActivitiesDto = uniqBy(
|
|
||||||
activitiesDto,
|
|
||||||
({ dataSource, symbol }) => {
|
|
||||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol }
|
{ currency, dataSource, symbol, type }
|
||||||
] of uniqueActivitiesDto.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
if (dataSource !== 'MANUAL') {
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
const assetProfile = (
|
throw new Error(
|
||||||
await this.dataProviderService.getAssetProfiles([
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
{ dataSource, symbol }
|
);
|
||||||
])
|
}
|
||||||
)?.[symbol];
|
|
||||||
|
|
||||||
if (!assetProfile?.name) {
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
const dataProvider = this.dataProviderService.getDataProvider(
|
||||||
|
DataSource[dataSource]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dataProvider.getDataProviderInfo().isPremium) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||||
assetProfile.currency !== currency &&
|
const assetProfile = {
|
||||||
!this.exchangeRateDataService.hasCurrencyPair(
|
currency,
|
||||||
currency,
|
...(
|
||||||
assetProfile.currency
|
await this.dataProviderService.getAssetProfiles([
|
||||||
)
|
{ dataSource, symbol }
|
||||||
) {
|
])
|
||||||
throw new Error(
|
)?.[symbol]
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
};
|
||||||
);
|
|
||||||
|
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||||
|
if (!assetProfile?.name) {
|
||||||
|
throw new Error(
|
||||||
|
`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 })] =
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
@ -10,6 +10,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -8,14 +8,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
|||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
DEFAULT_REQUEST_TIMEOUT,
|
|
||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -30,6 +28,7 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
@ -58,14 +57,9 @@ export class InfoService {
|
|||||||
const platforms = await this.platformService.getPlatforms({
|
const platforms = await this.platformService.getPlatforms({
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
|
||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
|
||||||
globalPermissions.push(permissions.enableBlog);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
@ -104,10 +98,6 @@ export class InfoService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||||
globalPermissions.push(permissions.enableSystemMessage);
|
globalPermissions.push(permissions.enableSystemMessage);
|
||||||
|
|
||||||
systemMessage = (await this.propertyService.getByKey(
|
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
|
||||||
)) as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
@ -135,7 +125,6 @@ export class InfoService {
|
|||||||
platforms,
|
platforms,
|
||||||
statistics,
|
statistics,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
systemMessage,
|
|
||||||
tags,
|
tags,
|
||||||
baseCurrency: DEFAULT_CURRENCY,
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies()
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
@ -169,7 +158,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { pull_count } = await got(
|
const { pull_count } = await got(
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
@ -194,7 +183,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -203,11 +192,11 @@ export class InfoService {
|
|||||||
|
|
||||||
const $ = cheerio.load(body);
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
return extractNumberFromString(
|
return extractNumberFromString({
|
||||||
$(
|
value: $(
|
||||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
).text()
|
).text()
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService - GitHub');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
@ -221,7 +210,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { stargazers_count } = await got(
|
const { stargazers_count } = await got(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
@ -349,7 +338,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { data } = await got(
|
const { data } = await got(
|
||||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
@ -359,7 +348,7 @@ export class InfoService {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.configurationService.get(
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
'BETTER_UPTIME_API_KEY'
|
'API_KEY_BETTER_UPTIME'
|
||||||
)}`
|
)}`
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { LogoController } from './logo.controller';
|
import { LogoController } from './logo.controller';
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import got from 'got';
|
import got from 'got';
|
||||||
@ -9,6 +10,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogoService {
|
export class LogoService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -46,7 +48,7 @@ export class LogoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, DEFAULT_REQUEST_TIMEOUT);
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
return got(
|
return got(
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
@ -10,10 +10,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -37,9 +39,13 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsISO4217CurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
customCurrency?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
@ -48,9 +54,11 @@ export class CreateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -64,6 +72,7 @@ export class CreateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
|
||||||
|
import { AccountWithPlatform } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Order, Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends Order {
|
||||||
|
Account?: AccountWithPlatform;
|
||||||
error?: ActivityError;
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
SymbolProfile?: EnhancedSymbolProfile;
|
||||||
|
tags?: Tag[];
|
||||||
updateAccountBalance?: boolean;
|
updateAccountBalance?: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
|
HEADER_KEY_IMPERSONATION
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -24,7 +31,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Order as OrderModel } from '@prisma/client';
|
import { Order as OrderModel, Prisma } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -44,24 +51,16 @@ export class OrderController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteOrder)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrders(): Promise<number> {
|
public async deleteOrders(): Promise<number> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
const order = await this.orderService.order({ id });
|
const order = await this.orderService.order({ id });
|
||||||
|
|
||||||
@ -82,14 +81,17 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
@ -99,12 +101,18 @@ export class OrderController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange);
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
startDate,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
@ -113,20 +121,21 @@ export class OrderController {
|
|||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
const currency = data.currency;
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
const customCurrency = data.customCurrency;
|
||||||
) {
|
|
||||||
throw new HttpException(
|
if (customCurrency) {
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
data.currency = customCurrency;
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
delete data.customCurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
@ -135,7 +144,7 @@ export class OrderController {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency: data.currency,
|
currency,
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
},
|
},
|
||||||
@ -154,31 +163,31 @@ export class OrderController {
|
|||||||
if (data.dataSource && !order.isDraft) {
|
if (data.dataSource && !order.isDraft) {
|
||||||
// Gather symbol data in the background, if data source is set
|
// Gather symbol data in the background, if data source is set
|
||||||
// (not MANUAL) and not draft
|
// (not MANUAL) and not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols({
|
||||||
{
|
dataGatheringItems: [
|
||||||
dataSource: data.dataSource,
|
{
|
||||||
date: order.date,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
date: order.date,
|
||||||
}
|
symbol: data.symbol
|
||||||
]);
|
}
|
||||||
|
],
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
|
||||||
!originalOrder ||
|
|
||||||
originalOrder.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -188,8 +197,16 @@ export class OrderController {
|
|||||||
const date = parseISO(data.date);
|
const date = parseISO(data.date);
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
|
const customCurrency = data.customCurrency;
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
|
||||||
|
if (customCurrency) {
|
||||||
|
data.currency = customCurrency;
|
||||||
|
|
||||||
|
delete data.customCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
return this.orderService.updateOrder({
|
return this.orderService.updateOrder({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
@ -11,6 +11,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
|
@ -4,12 +4,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
@ -18,14 +20,15 @@ import {
|
|||||||
Order,
|
Order,
|
||||||
Prisma,
|
Prisma,
|
||||||
Tag,
|
Tag,
|
||||||
Type as TypeOfOrder
|
Type as ActivityType
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy, uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { CreateOrderDto } from './create-order.dto';
|
||||||
|
import { Activities } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
@ -37,34 +40,6 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
|
||||||
): Promise<Order | null> {
|
|
||||||
return this.prismaService.order.findUnique({
|
|
||||||
where: orderWhereUniqueInput
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async orders(params: {
|
|
||||||
include?: Prisma.OrderInclude;
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
cursor?: Prisma.OrderWhereUniqueInput;
|
|
||||||
where?: Prisma.OrderWhereInput;
|
|
||||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
|
||||||
}): Promise<OrderWithAccount[]> {
|
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
|
||||||
|
|
||||||
return this.prismaService.order.findMany({
|
|
||||||
cursor,
|
|
||||||
include,
|
|
||||||
orderBy,
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
where
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -92,20 +67,13 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
let currency = data.currency;
|
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
data.type === 'FEE' ||
|
|
||||||
data.type === 'INTEREST' ||
|
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
@ -113,7 +81,6 @@ export class OrderService {
|
|||||||
data.id = id;
|
data.id = id;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
|
||||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||||
@ -135,7 +102,8 @@ export class OrderService {
|
|||||||
jobId: getAssetProfileIdentifier({
|
jobId: getAssetProfileIdentifier({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
})
|
}),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -148,7 +116,6 @@ export class OrderService {
|
|||||||
delete data.comment;
|
delete data.comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.currency;
|
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -157,13 +124,9 @@ export class OrderService {
|
|||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
const isDraft =
|
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||||
data.type === 'FEE' ||
|
? false
|
||||||
data.type === 'INTEREST' ||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
? false
|
|
||||||
: isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
@ -184,15 +147,15 @@ export class OrderService {
|
|||||||
.plus(data.fee)
|
.plus(data.fee)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
if (data.type === 'BUY') {
|
if (['BUY', 'FEE'].includes(data.type)) {
|
||||||
amount = new Big(amount).mul(-1).toNumber();
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.accountService.updateAccountBalance({
|
await this.accountService.updateAccountBalance({
|
||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
|
||||||
userId,
|
userId,
|
||||||
|
currency: data.SymbolProfile.connectOrCreate.create.currency,
|
||||||
date: data.date as Date
|
date: data.date as Date
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -207,12 +170,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||||
order.type === 'FEE' ||
|
|
||||||
order.type === 'INTEREST' ||
|
|
||||||
order.type === 'ITEM' ||
|
|
||||||
order.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,27 +185,61 @@ export class OrderService {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return this.prismaService.order.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
SymbolProfile: { dataSource, symbol }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
skip,
|
skip,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
startDate,
|
||||||
take = Number.MAX_SAFE_INTEGER,
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts = false
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
startDate?: Date;
|
||||||
take?: number;
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: ActivityType[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activities> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
|
{ date: 'asc' }
|
||||||
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
if (endDate || startDate) {
|
||||||
|
where.AND = [];
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
where.AND.push({ date: { lte: endDate } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
where.AND.push({ date: { gt: startDate } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
@ -307,18 +299,24 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types) {
|
if (sortColumn) {
|
||||||
where.OR = types.map((type) => {
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
return {
|
|
||||||
type: {
|
|
||||||
equals: type
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (types) {
|
||||||
await this.orders({
|
where.type = { in: types };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.OR = [
|
||||||
|
{ Account: null },
|
||||||
|
{ Account: { NOT: { isExcluded: true } } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, count] = await Promise.all([
|
||||||
|
this.orders({
|
||||||
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
@ -332,35 +330,67 @@ export class OrderService {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
SymbolProfile: true,
|
SymbolProfile: true,
|
||||||
tags: true
|
tags: true
|
||||||
},
|
}
|
||||||
orderBy: { date: 'asc' }
|
}),
|
||||||
})
|
this.prismaService.order.count({ where })
|
||||||
)
|
]);
|
||||||
.filter((order) => {
|
|
||||||
return (
|
|
||||||
withExcludedAccounts ||
|
|
||||||
!order.Account ||
|
|
||||||
order.Account?.isExcluded === false
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
|
||||||
|
|
||||||
|
const uniqueAssets = uniqBy(
|
||||||
|
orders.map(({ SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
...order,
|
dataSource: SymbolProfile.dataSource,
|
||||||
value,
|
symbol: SymbolProfile.symbol
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
}),
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||||
|
|
||||||
|
const activities = orders.map((order) => {
|
||||||
|
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||||
|
return (
|
||||||
|
dataSource === order.SymbolProfile.dataSource &&
|
||||||
|
symbol === order.SymbolProfile.symbol
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
// TODO: Use exchange rate of date
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
|
// TODO: Use exchange rate of date
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { activities, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async order(
|
||||||
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
|
): Promise<Order | null> {
|
||||||
|
return this.prismaService.order.findUnique({
|
||||||
|
where: orderWhereUniqueInput
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder({
|
public async updateOrder({
|
||||||
@ -374,13 +404,10 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
type?: ActivityType;
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
if (data.Account.connect.id_userId.id === null) {
|
|
||||||
delete data.Account;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.comment) {
|
if (!data.comment) {
|
||||||
data.comment = null;
|
data.comment = null;
|
||||||
}
|
}
|
||||||
@ -389,13 +416,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
data.type === 'FEE' ||
|
|
||||||
data.type === 'INTEREST' ||
|
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
|
|
||||||
|
if (data.Account?.connect?.id_userId?.id === null) {
|
||||||
|
data.Account = { disconnect: true };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
@ -403,19 +429,22 @@ export class OrderService {
|
|||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols({
|
||||||
{
|
dataGatheringItems: [
|
||||||
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
{
|
||||||
date: <Date>data.date,
|
dataSource:
|
||||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||||
}
|
date: <Date>data.date,
|
||||||
]);
|
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||||
|
}
|
||||||
|
],
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -439,4 +468,24 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async orders(params: {
|
||||||
|
include?: Prisma.OrderInclude;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
cursor?: Prisma.OrderWhereUniqueInput;
|
||||||
|
where?: Prisma.OrderWhereInput;
|
||||||
|
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||||
|
}): Promise<OrderWithAccount[]> {
|
||||||
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
|
return this.prismaService.order.findMany({
|
||||||
|
cursor,
|
||||||
|
include,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,13 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -37,9 +38,13 @@ export class UpdateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsISO4217CurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
customCurrency?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@ -47,12 +52,14 @@ export class UpdateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -66,5 +73,6 @@ export class UpdateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
export class CreatePlatformDto {
|
export class CreatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Platform } from '@prisma/client';
|
import { Platform } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -23,49 +23,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
|||||||
|
|
||||||
@Controller('platform')
|
@Controller('platform')
|
||||||
export class PlatformController {
|
export class PlatformController {
|
||||||
public constructor(
|
public constructor(private readonly platformService: PlatformService) {}
|
||||||
private readonly platformService: PlatformService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPlatforms() {
|
public async getPlatforms() {
|
||||||
return this.platformService.getPlatformsWithAccountCount();
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createPlatform)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createPlatform(
|
public async createPlatform(
|
||||||
@Body() data: CreatePlatformDto
|
@Body() data: CreatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updatePlatform)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updatePlatform(
|
public async updatePlatform(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() data: UpdatePlatformDto
|
@Body() data: UpdatePlatformDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
@ -88,17 +69,9 @@ export class PlatformController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deletePlatform)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deletePlatform(@Param('id') id: string) {
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PlatformController } from './platform.controller';
|
import { PlatformController } from './platform.controller';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Platform, Prisma } from '@prisma/client';
|
import { Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsString, IsUrl } from 'class-validator';
|
||||||
|
|
||||||
export class UpdatePlatformDto {
|
export class UpdatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||||
|
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
|
||||||
|
import {
|
||||||
|
SymbolMetrics,
|
||||||
|
TimelinePosition,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||||
|
protected calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): PortfolioSnapshot {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode = false,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step = 1,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
export const activityDummyData = {
|
||||||
|
accountId: undefined,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
currency: undefined,
|
||||||
|
feeInBaseCurrency: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isDraft: false,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: undefined,
|
||||||
|
value: undefined,
|
||||||
|
valueInBaseCurrency: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export const symbolProfileDummyData = {
|
||||||
|
activitiesCount: undefined,
|
||||||
|
assetClass: undefined,
|
||||||
|
assetSubClass: undefined,
|
||||||
|
countries: [],
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
sectors: [],
|
||||||
|
updatedAt: undefined
|
||||||
|
};
|
@ -0,0 +1,56 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
|
||||||
|
|
||||||
|
export enum PerformanceCalculationType {
|
||||||
|
MWR = 'MWR', // Money-Weighted Rate of Return
|
||||||
|
TWR = 'TWR' // Time-Weighted Rate of Return
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortfolioCalculatorFactory {
|
||||||
|
public constructor(
|
||||||
|
private readonly currentRateService: CurrentRateService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType,
|
||||||
|
currency,
|
||||||
|
dateRange = 'max'
|
||||||
|
}: {
|
||||||
|
activities: Activity[];
|
||||||
|
calculationType: PerformanceCalculationType;
|
||||||
|
currency: string;
|
||||||
|
dateRange?: DateRange;
|
||||||
|
}): PortfolioCalculator {
|
||||||
|
switch (calculationType) {
|
||||||
|
case PerformanceCalculationType.MWR:
|
||||||
|
return new MWRPortfolioCalculator({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
dateRange,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService
|
||||||
|
});
|
||||||
|
case PerformanceCalculationType.TWR:
|
||||||
|
return new TWRPortfolioCalculator({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
dateRange,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid calculation type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
928
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
928
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
@ -0,0 +1,928 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||||
|
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
|
||||||
|
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
|
||||||
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
|
import {
|
||||||
|
getFactor,
|
||||||
|
getInterval
|
||||||
|
} from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getSum,
|
||||||
|
parseDate,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
HistoricalDataItem,
|
||||||
|
InvestmentItem,
|
||||||
|
ResponseError,
|
||||||
|
SymbolMetrics,
|
||||||
|
TimelinePosition,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import {
|
||||||
|
differenceInDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
isBefore,
|
||||||
|
isSameDay,
|
||||||
|
max,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
|
import { last, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
|
export abstract class PortfolioCalculator {
|
||||||
|
protected static readonly ENABLE_LOGGING = false;
|
||||||
|
|
||||||
|
protected orders: PortfolioOrder[];
|
||||||
|
|
||||||
|
private currency: string;
|
||||||
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
|
private endDate: Date;
|
||||||
|
private exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
private snapshot: PortfolioSnapshot;
|
||||||
|
private snapshotPromise: Promise<void>;
|
||||||
|
private startDate: Date;
|
||||||
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService,
|
||||||
|
dateRange,
|
||||||
|
exchangeRateDataService
|
||||||
|
}: {
|
||||||
|
activities: Activity[];
|
||||||
|
currency: string;
|
||||||
|
currentRateService: CurrentRateService;
|
||||||
|
dateRange: DateRange;
|
||||||
|
exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
}) {
|
||||||
|
this.currency = currency;
|
||||||
|
this.currentRateService = currentRateService;
|
||||||
|
this.exchangeRateDataService = exchangeRateDataService;
|
||||||
|
this.orders = activities.map(
|
||||||
|
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
|
||||||
|
return {
|
||||||
|
SymbolProfile,
|
||||||
|
tags,
|
||||||
|
type,
|
||||||
|
date: format(date, DATE_FORMAT),
|
||||||
|
fee: new Big(fee),
|
||||||
|
quantity: new Big(quantity),
|
||||||
|
unitPrice: new Big(unitPrice)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.orders.sort((a, b) => {
|
||||||
|
return a.date?.localeCompare(b.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange);
|
||||||
|
|
||||||
|
this.endDate = endDate;
|
||||||
|
this.startDate = startDate;
|
||||||
|
|
||||||
|
this.computeTransactionPoints();
|
||||||
|
|
||||||
|
this.snapshotPromise = this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): PortfolioSnapshot;
|
||||||
|
|
||||||
|
public async computeSnapshot(
|
||||||
|
start: Date,
|
||||||
|
end?: Date
|
||||||
|
): Promise<PortfolioSnapshot> {
|
||||||
|
const lastTransactionPoint = last(this.transactionPoints);
|
||||||
|
|
||||||
|
let endDate = end;
|
||||||
|
|
||||||
|
if (!endDate) {
|
||||||
|
endDate = new Date(Date.now());
|
||||||
|
|
||||||
|
if (lastTransactionPoint) {
|
||||||
|
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
|
||||||
|
return isBefore(parseDate(date), endDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transactionPoints.length) {
|
||||||
|
return {
|
||||||
|
currentValueInBaseCurrency: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalFeesWithCurrencyEffect: new Big(0),
|
||||||
|
totalInterestWithCurrencyEffect: new Big(0),
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
let dates: Date[] = [];
|
||||||
|
let firstIndex = transactionPoints.length;
|
||||||
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
|
let totalInterestWithCurrencyEffect = new Big(0);
|
||||||
|
let totalLiabilitiesWithCurrencyEffect = new Big(0);
|
||||||
|
let totalValuablesWithCurrencyEffect = new Big(0);
|
||||||
|
|
||||||
|
dates.push(resetHours(start));
|
||||||
|
|
||||||
|
for (const { currency, dataSource, symbol } of transactionPoints[
|
||||||
|
firstIndex - 1
|
||||||
|
].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
currencies[symbol] = currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < transactionPoints.length; i++) {
|
||||||
|
if (
|
||||||
|
!isBefore(parseDate(transactionPoints[i].date), start) &&
|
||||||
|
firstTransactionPoint === null
|
||||||
|
) {
|
||||||
|
firstTransactionPoint = transactionPoints[i];
|
||||||
|
firstIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTransactionPoint !== null) {
|
||||||
|
dates.push(resetHours(parseDate(transactionPoints[i].date)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.push(resetHours(endDate));
|
||||||
|
|
||||||
|
// Add dates of last week for fallback
|
||||||
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a.getTime() - b.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
let exchangeRatesByCurrency =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
currencies: uniq(Object.values(currencies)),
|
||||||
|
endDate: endOfDay(endDate),
|
||||||
|
startDate: this.getStartDate(),
|
||||||
|
targetCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDateString = format(endDate, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (firstIndex > 0) {
|
||||||
|
firstIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: TimelinePosition[] = [];
|
||||||
|
let hasAnySymbolMetricsErrors = false;
|
||||||
|
|
||||||
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const marketPriceInBaseCurrency = (
|
||||||
|
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||||
|
).mul(
|
||||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||||
|
endDateString
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect,
|
||||||
|
grossPerformanceWithCurrencyEffect,
|
||||||
|
hasErrors,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
|
netPerformancePercentageWithCurrencyEffect,
|
||||||
|
netPerformanceWithCurrencyEffect,
|
||||||
|
timeWeightedInvestment,
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect,
|
||||||
|
totalDividend,
|
||||||
|
totalDividendInBaseCurrency,
|
||||||
|
totalInterestInBaseCurrency,
|
||||||
|
totalInvestment,
|
||||||
|
totalInvestmentWithCurrencyEffect,
|
||||||
|
totalLiabilitiesInBaseCurrency,
|
||||||
|
totalValuablesInBaseCurrency
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
end: endDate,
|
||||||
|
exchangeRates:
|
||||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
dividend: totalDividend,
|
||||||
|
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||||
|
timeWeightedInvestment,
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect,
|
||||||
|
averagePrice: item.averagePrice,
|
||||||
|
currency: item.currency,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
fee: item.fee,
|
||||||
|
firstBuyDate: item.firstBuyDate,
|
||||||
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
|
grossPerformancePercentage: !hasErrors
|
||||||
|
? grossPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
|
? grossPerformancePercentageWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: !hasErrors
|
||||||
|
? grossPerformanceWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
investment: totalInvestment,
|
||||||
|
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
|
||||||
|
marketPrice:
|
||||||
|
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
|
||||||
|
marketPriceInBaseCurrency:
|
||||||
|
marketPriceInBaseCurrency?.toNumber() ?? null,
|
||||||
|
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||||
|
netPerformancePercentage: !hasErrors
|
||||||
|
? netPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
|
? netPerformancePercentageWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
netPerformanceWithCurrencyEffect: !hasErrors
|
||||||
|
? netPerformanceWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
|
transactionCount: item.transactionCount,
|
||||||
|
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
|
||||||
|
item.quantity
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus(
|
||||||
|
totalInterestInBaseCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
totalLiabilitiesWithCurrencyEffect =
|
||||||
|
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
|
||||||
|
|
||||||
|
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
|
||||||
|
totalValuablesInBaseCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(hasErrors ||
|
||||||
|
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||||
|
return dataSource === item.dataSource && symbol === item.symbol;
|
||||||
|
})) &&
|
||||||
|
item.investment.gt(0)
|
||||||
|
) {
|
||||||
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = this.calculateOverallPerformance(positions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overall,
|
||||||
|
errors,
|
||||||
|
positions,
|
||||||
|
totalInterestWithCurrencyEffect,
|
||||||
|
totalLiabilitiesWithCurrencyEffect,
|
||||||
|
totalValuablesWithCurrencyEffect,
|
||||||
|
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getChart({
|
||||||
|
dateRange = 'max',
|
||||||
|
withDataDecimation = true
|
||||||
|
}: {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
withDataDecimation?: boolean;
|
||||||
|
}): Promise<HistoricalDataItem[]> {
|
||||||
|
if (this.getTransactionPoints().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
|
||||||
|
|
||||||
|
const daysInMarket = differenceInDays(endDate, startDate) + 1;
|
||||||
|
const step = withDataDecimation
|
||||||
|
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS))
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return this.getChartData({
|
||||||
|
step,
|
||||||
|
end: endDate,
|
||||||
|
start: startDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getChartData({
|
||||||
|
end = new Date(Date.now()),
|
||||||
|
start,
|
||||||
|
step = 1
|
||||||
|
}: {
|
||||||
|
end?: Date;
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
}): Promise<HistoricalDataItem[]> {
|
||||||
|
const symbols: { [symbol: string]: boolean } = {};
|
||||||
|
|
||||||
|
const transactionPointsBeforeEndDate =
|
||||||
|
this.transactionPoints?.filter((transactionPoint) => {
|
||||||
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
|
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => {
|
||||||
|
return resetHours(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const includesEndDate = isSameDay(last(dates), end);
|
||||||
|
|
||||||
|
if (!includesEndDate) {
|
||||||
|
dates.push(resetHours(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionPointsBeforeEndDate.length > 0) {
|
||||||
|
for (const {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
} of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
currencies[symbol] = currency;
|
||||||
|
symbols[symbol] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
|
await this.currentRateService.getValues({
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
let exchangeRatesByCurrency =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
currencies: uniq(Object.values(currencies)),
|
||||||
|
endDate: endOfDay(end),
|
||||||
|
startDate: this.getStartDate(),
|
||||||
|
targetCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const dateString = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[dateString]) {
|
||||||
|
marketSymbolMap[dateString] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accumulatedValuesByDate: {
|
||||||
|
[date: string]: {
|
||||||
|
investmentValueWithCurrencyEffect: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalCurrentValueWithCurrencyEffect: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalInvestmentValueWithCurrencyEffect: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect: Big;
|
||||||
|
totalTimeWeightedInvestmentValue: Big;
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const valuesBySymbol: {
|
||||||
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
currentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
investmentValuesAccumulated: { [date: string]: Big };
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
investmentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
timeWeightedInvestmentValues: { [date: string]: Big };
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(symbols)) {
|
||||||
|
const {
|
||||||
|
currentValues,
|
||||||
|
currentValuesWithCurrencyEffect,
|
||||||
|
investmentValuesAccumulated,
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect,
|
||||||
|
investmentValuesWithCurrencyEffect,
|
||||||
|
netPerformanceValues,
|
||||||
|
netPerformanceValuesWithCurrencyEffect,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
end,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
symbol,
|
||||||
|
dataSource: null,
|
||||||
|
exchangeRates:
|
||||||
|
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`],
|
||||||
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
valuesBySymbol[symbol] = {
|
||||||
|
currentValues,
|
||||||
|
currentValuesWithCurrencyEffect,
|
||||||
|
investmentValuesAccumulated,
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect,
|
||||||
|
investmentValuesWithCurrencyEffect,
|
||||||
|
netPerformanceValues,
|
||||||
|
netPerformanceValuesWithCurrencyEffect,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const currentDate of dates) {
|
||||||
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
|
|
||||||
|
const currentValue =
|
||||||
|
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const currentValueWithCurrencyEffect =
|
||||||
|
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const investmentValueAccumulated =
|
||||||
|
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const investmentValueAccumulatedWithCurrencyEffect =
|
||||||
|
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
|
||||||
|
dateString
|
||||||
|
] ?? new Big(0);
|
||||||
|
|
||||||
|
const investmentValueWithCurrencyEffect =
|
||||||
|
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const netPerformanceValue =
|
||||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const netPerformanceValueWithCurrencyEffect =
|
||||||
|
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const timeWeightedInvestmentValue =
|
||||||
|
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const timeWeightedInvestmentValueWithCurrencyEffect =
|
||||||
|
symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[
|
||||||
|
dateString
|
||||||
|
] ?? new Big(0);
|
||||||
|
|
||||||
|
accumulatedValuesByDate[dateString] = {
|
||||||
|
investmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.investmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(investmentValueWithCurrencyEffect),
|
||||||
|
totalCurrentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
|
).add(currentValue),
|
||||||
|
totalCurrentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(currentValueWithCurrencyEffect),
|
||||||
|
totalInvestmentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||||
|
new Big(0)
|
||||||
|
).add(investmentValueAccumulated),
|
||||||
|
totalInvestmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(investmentValueAccumulatedWithCurrencyEffect),
|
||||||
|
totalNetPerformanceValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||||
|
new Big(0)
|
||||||
|
).add(netPerformanceValue),
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(netPerformanceValueWithCurrencyEffect),
|
||||||
|
totalTimeWeightedInvestmentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalTimeWeightedInvestmentValue ?? new Big(0)
|
||||||
|
).add(timeWeightedInvestmentValue),
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(timeWeightedInvestmentValueWithCurrencyEffect)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||||
|
const {
|
||||||
|
investmentValueWithCurrencyEffect,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalCurrentValueWithCurrencyEffect,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalInvestmentValueWithCurrencyEffect,
|
||||||
|
totalNetPerformanceValue,
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect,
|
||||||
|
totalTimeWeightedInvestmentValue,
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0)
|
||||||
|
? 0
|
||||||
|
: totalNetPerformanceValue
|
||||||
|
.div(totalTimeWeightedInvestmentValue)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
const netPerformanceInPercentageWithCurrencyEffect =
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0)
|
||||||
|
? 0
|
||||||
|
: totalNetPerformanceValueWithCurrencyEffect
|
||||||
|
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect,
|
||||||
|
investmentValueWithCurrencyEffect:
|
||||||
|
investmentValueWithCurrencyEffect.toNumber(),
|
||||||
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
|
netPerformanceWithCurrencyEffect:
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
|
||||||
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
|
totalInvestmentValueWithCurrencyEffect:
|
||||||
|
totalInvestmentValueWithCurrencyEffect.toNumber(),
|
||||||
|
value: totalCurrentValue.toNumber(),
|
||||||
|
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDividendInBaseCurrency() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return getSum(
|
||||||
|
this.snapshot.positions.map(({ dividendInBaseCurrency }) => {
|
||||||
|
return dividendInBaseCurrency;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFeesInBaseCurrency() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return this.snapshot.totalFeesWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getInterestInBaseCurrency() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return this.snapshot.totalInterestWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
|
if (this.transactionPoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.transactionPoints.map((transactionPoint) => {
|
||||||
|
return {
|
||||||
|
date: transactionPoint.date,
|
||||||
|
investment: transactionPoint.items.reduce(
|
||||||
|
(investment, transactionPointSymbol) =>
|
||||||
|
investment.plus(transactionPointSymbol.investment),
|
||||||
|
new Big(0)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestmentsByGroup({
|
||||||
|
data,
|
||||||
|
groupBy
|
||||||
|
}: {
|
||||||
|
data: HistoricalDataItem[];
|
||||||
|
groupBy: GroupBy;
|
||||||
|
}): InvestmentItem[] {
|
||||||
|
const groupedData: { [dateGroup: string]: Big } = {};
|
||||||
|
|
||||||
|
for (const { date, investmentValueWithCurrencyEffect } of data) {
|
||||||
|
const dateGroup =
|
||||||
|
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4);
|
||||||
|
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus(
|
||||||
|
investmentValueWithCurrencyEffect
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(groupedData).map((dateGroup) => ({
|
||||||
|
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`,
|
||||||
|
investment: groupedData[dateGroup].toNumber()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLiabilitiesInBaseCurrency() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return this.snapshot.totalLiabilitiesWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSnapshot() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return this.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStartDate() {
|
||||||
|
return this.transactionPoints.length > 0
|
||||||
|
? parseDate(this.transactionPoints[0].date)
|
||||||
|
: new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics;
|
||||||
|
|
||||||
|
public getTransactionPoints() {
|
||||||
|
return this.transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getValuablesInBaseCurrency() {
|
||||||
|
await this.snapshotPromise;
|
||||||
|
|
||||||
|
return this.snapshot.totalValuablesWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeTransactionPoints() {
|
||||||
|
this.transactionPoints = [];
|
||||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
|
let lastDate: string = null;
|
||||||
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
fee,
|
||||||
|
date,
|
||||||
|
quantity,
|
||||||
|
SymbolProfile,
|
||||||
|
tags,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
} of this.orders) {
|
||||||
|
let currentTransactionPointItem: TransactionPointSymbol;
|
||||||
|
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
|
||||||
|
|
||||||
|
const factor = getFactor(type);
|
||||||
|
|
||||||
|
if (oldAccumulatedSymbol) {
|
||||||
|
let investment = oldAccumulatedSymbol.investment;
|
||||||
|
|
||||||
|
const newQuantity = quantity
|
||||||
|
.mul(factor)
|
||||||
|
.plus(oldAccumulatedSymbol.quantity);
|
||||||
|
|
||||||
|
if (type === 'BUY') {
|
||||||
|
investment = oldAccumulatedSymbol.investment.plus(
|
||||||
|
quantity.mul(unitPrice)
|
||||||
|
);
|
||||||
|
} else if (type === 'SELL') {
|
||||||
|
investment = oldAccumulatedSymbol.investment.minus(
|
||||||
|
quantity.mul(oldAccumulatedSymbol.averagePrice)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
investment,
|
||||||
|
averagePrice: newQuantity.gt(0)
|
||||||
|
? investment.div(newQuantity)
|
||||||
|
: new Big(0),
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
dividend: new Big(0),
|
||||||
|
fee: oldAccumulatedSymbol.fee.plus(fee),
|
||||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
|
quantity: newQuantity,
|
||||||
|
symbol: SymbolProfile.symbol,
|
||||||
|
tags: oldAccumulatedSymbol.tags.concat(tags),
|
||||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
fee,
|
||||||
|
tags,
|
||||||
|
averagePrice: unitPrice,
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
dividend: new Big(0),
|
||||||
|
firstBuyDate: date,
|
||||||
|
investment: unitPrice.mul(quantity).mul(factor),
|
||||||
|
quantity: quantity.mul(factor),
|
||||||
|
symbol: SymbolProfile.symbol,
|
||||||
|
transactionCount: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTransactionPointItem.tags = uniqBy(
|
||||||
|
currentTransactionPointItem.tags,
|
||||||
|
'id'
|
||||||
|
);
|
||||||
|
|
||||||
|
symbols[SymbolProfile.symbol] = currentTransactionPointItem;
|
||||||
|
|
||||||
|
const items = lastTransactionPoint?.items ?? [];
|
||||||
|
|
||||||
|
const newItems = items.filter(({ symbol }) => {
|
||||||
|
return symbol !== SymbolProfile.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
newItems.push(currentTransactionPointItem);
|
||||||
|
|
||||||
|
newItems.sort((a, b) => {
|
||||||
|
return a.symbol?.localeCompare(b.symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
let fees = new Big(0);
|
||||||
|
|
||||||
|
if (type === 'FEE') {
|
||||||
|
fees = fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
let interest = new Big(0);
|
||||||
|
|
||||||
|
if (type === 'INTEREST') {
|
||||||
|
interest = quantity.mul(unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
let liabilities = new Big(0);
|
||||||
|
|
||||||
|
if (type === 'LIABILITY') {
|
||||||
|
liabilities = quantity.mul(unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
let valuables = new Big(0);
|
||||||
|
|
||||||
|
if (type === 'ITEM') {
|
||||||
|
valuables = quantity.mul(unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastDate !== date || lastTransactionPoint === null) {
|
||||||
|
lastTransactionPoint = {
|
||||||
|
date,
|
||||||
|
fees,
|
||||||
|
interest,
|
||||||
|
liabilities,
|
||||||
|
valuables,
|
||||||
|
items: newItems
|
||||||
|
};
|
||||||
|
|
||||||
|
this.transactionPoints.push(lastTransactionPoint);
|
||||||
|
} else {
|
||||||
|
lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees);
|
||||||
|
lastTransactionPoint.interest =
|
||||||
|
lastTransactionPoint.interest.plus(interest);
|
||||||
|
lastTransactionPoint.items = newItems;
|
||||||
|
lastTransactionPoint.liabilities =
|
||||||
|
lastTransactionPoint.liabilities.plus(liabilities);
|
||||||
|
lastTransactionPoint.valuables =
|
||||||
|
lastTransactionPoint.valuables.plus(valuables);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize() {
|
||||||
|
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,195 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-22'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2021-11-22')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2021-11-22')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.04408677396780965649'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.05528341497550734703'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('3.2'),
|
||||||
|
firstBuyDate: '2021-11-22',
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.04408677396780965649'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.05528341497550734703'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'285.80000000000000396627'
|
||||||
|
),
|
||||||
|
transactionCount: 3,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: 0 },
|
||||||
|
{ date: '2021-12-01', investment: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,178 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-22'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2021-11-22')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2021-11-22')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0440867739678096571'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0552834149755073478'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('3.2'),
|
||||||
|
firstBuyDate: '2021-11-22',
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0440867739678096571'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0552834149755073478'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('285.8'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||||
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: 0 },
|
||||||
|
{ date: '2021-12-01', investment: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,162 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2021-11-30')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2021-11-30')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('297.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.09004392386530014641'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.08437042459736456808'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('136.6'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('1.55'),
|
||||||
|
firstBuyDate: '2021-11-30',
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.09004392386530014641'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||||
|
investment: new Big('273.2'),
|
||||||
|
investmentWithCurrencyEffect: new Big('273.2'),
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.08437042459736456808'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
|
quantity: new Big('2'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('273.2'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('297.8')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('1.55'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('273.2'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: 273.2 },
|
||||||
|
{ date: '2021-12-01', investment: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,232 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BTCUSD buy and sell partially', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2015-01-01'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
symbol: 'BTCUSD'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 320.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2017-12-31'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
symbol: 'BTCUSD'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 14156.4
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2015-01-01')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2015-01-01')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('27172.74'),
|
||||||
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('27172.74'),
|
||||||
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('320.43'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2015-01-01',
|
||||||
|
grossPerformance: new Big('27172.74'),
|
||||||
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(
|
||||||
|
'26516.208701400000064086'
|
||||||
|
),
|
||||||
|
investment: new Big('320.43'),
|
||||||
|
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||||
|
marketPrice: 13657.2,
|
||||||
|
marketPriceInBaseCurrency: 13298.425356,
|
||||||
|
netPerformance: new Big('27172.74'),
|
||||||
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(
|
||||||
|
'26516.208701400000064086'
|
||||||
|
),
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'BTCUSD',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'636.79469348020066587024'
|
||||||
|
),
|
||||||
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('13298.425356')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('320.43'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||||
|
{ date: '2017-12-31', investment: new Big('320.43') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2015-01-01', investment: 637.0853345999999 },
|
||||||
|
{ date: '2015-02-01', investment: 0 },
|
||||||
|
{ date: '2015-03-01', investment: 0 },
|
||||||
|
{ date: '2015-04-01', investment: 0 },
|
||||||
|
{ date: '2015-05-01', investment: 0 },
|
||||||
|
{ date: '2015-06-01', investment: 0 },
|
||||||
|
{ date: '2015-07-01', investment: 0 },
|
||||||
|
{ date: '2015-08-01', investment: 0 },
|
||||||
|
{ date: '2015-09-01', investment: 0 },
|
||||||
|
{ date: '2015-10-01', investment: 0 },
|
||||||
|
{ date: '2015-11-01', investment: 0 },
|
||||||
|
{ date: '2015-12-01', investment: 0 },
|
||||||
|
{ date: '2016-01-01', investment: 0 },
|
||||||
|
{ date: '2016-02-01', investment: 0 },
|
||||||
|
{ date: '2016-03-01', investment: 0 },
|
||||||
|
{ date: '2016-04-01', investment: 0 },
|
||||||
|
{ date: '2016-05-01', investment: 0 },
|
||||||
|
{ date: '2016-06-01', investment: 0 },
|
||||||
|
{ date: '2016-07-01', investment: 0 },
|
||||||
|
{ date: '2016-08-01', investment: 0 },
|
||||||
|
{ date: '2016-09-01', investment: 0 },
|
||||||
|
{ date: '2016-10-01', investment: 0 },
|
||||||
|
{ date: '2016-11-01', investment: 0 },
|
||||||
|
{ date: '2016-12-01', investment: 0 },
|
||||||
|
{ date: '2017-01-01', investment: 0 },
|
||||||
|
{ date: '2017-02-01', investment: 0 },
|
||||||
|
{ date: '2017-03-01', investment: 0 },
|
||||||
|
{ date: '2017-04-01', investment: 0 },
|
||||||
|
{ date: '2017-05-01', investment: 0 },
|
||||||
|
{ date: '2017-06-01', investment: 0 },
|
||||||
|
{ date: '2017-07-01', investment: 0 },
|
||||||
|
{ date: '2017-08-01', investment: 0 },
|
||||||
|
{ date: '2017-09-01', investment: 0 },
|
||||||
|
{ date: '2017-10-01', investment: 0 },
|
||||||
|
{ date: '2017-11-01', investment: 0 },
|
||||||
|
{ date: '2017-12-01', investment: -318.54266729999995 },
|
||||||
|
{ date: '2018-01-01', investment: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,134 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with fee activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-09-01'),
|
||||||
|
fee: 49,
|
||||||
|
quantity: 0,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Account Opening Fee',
|
||||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
||||||
|
},
|
||||||
|
type: 'FEE',
|
||||||
|
unitPrice: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2021-11-30')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('0'),
|
||||||
|
grossPerformancePercentage: new Big('0'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
hasErrors: true,
|
||||||
|
netPerformance: new Big('0'),
|
||||||
|
netPerformancePercentage: new Big('0'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('49'),
|
||||||
|
firstBuyDate: '2021-09-01',
|
||||||
|
grossPerformance: null,
|
||||||
|
grossPerformancePercentage: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: null,
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
marketPrice: null,
|
||||||
|
marketPriceInBaseCurrency: 0,
|
||||||
|
netPerformance: null,
|
||||||
|
netPerformancePercentage: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
netPerformanceWithCurrencyEffect: null,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('0'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('49'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,198 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with GOOGL buy', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2023-01-03'),
|
||||||
|
fee: 1,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Alphabet Inc.',
|
||||||
|
symbol: 'GOOGL'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 89.12
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2023-01-03')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2023-01-03')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('103.10483'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('27.33'),
|
||||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.25235044599563974109'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('26.33'),
|
||||||
|
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.24112962014285697628'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('89.12'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('1'),
|
||||||
|
firstBuyDate: '2023-01-03',
|
||||||
|
grossPerformance: new Big('27.33'),
|
||||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.25235044599563974109'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||||
|
investment: new Big('89.12'),
|
||||||
|
investmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
netPerformance: new Big('26.33'),
|
||||||
|
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.24112962014285697628'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||||
|
marketPrice: 116.45,
|
||||||
|
marketPriceInBaseCurrency: 103.10483,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'GOOGL',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('89.12'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('103.10483')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('1'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('89.12'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2023-01-03', investment: new Big('89.12') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2023-01-01', investment: 82.329056 },
|
||||||
|
{
|
||||||
|
date: '2023-02-01',
|
||||||
|
investment: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2023-03-01',
|
||||||
|
investment: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2023-04-01',
|
||||||
|
investment: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2023-05-01',
|
||||||
|
investment: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2023-06-01',
|
||||||
|
investment: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2023-07-01',
|
||||||
|
investment: 0
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,134 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with item activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-01-01'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Penthouse Apartment',
|
||||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
||||||
|
},
|
||||||
|
type: 'ITEM',
|
||||||
|
unitPrice: 500000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2022-01-01')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('0'),
|
||||||
|
grossPerformancePercentage: new Big('0'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
hasErrors: true,
|
||||||
|
netPerformance: new Big('0'),
|
||||||
|
netPerformancePercentage: new Big('0'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('500000'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2022-01-01',
|
||||||
|
grossPerformance: null,
|
||||||
|
grossPerformancePercentage: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: null,
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
marketPrice: null,
|
||||||
|
marketPriceInBaseCurrency: 500000,
|
||||||
|
netPerformance: null,
|
||||||
|
netPerformancePercentage: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
netPerformanceWithCurrencyEffect: null,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('0'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,134 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compute portfolio snapshot', () => {
|
||||||
|
it.only('with liability activity', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-01-01'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
name: 'Loan',
|
||||||
|
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
||||||
|
},
|
||||||
|
type: 'LIABILITY',
|
||||||
|
unitPrice: 3000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2022-01-01')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('0'),
|
||||||
|
grossPerformancePercentage: new Big('0'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
hasErrors: true,
|
||||||
|
netPerformance: new Big('0'),
|
||||||
|
netPerformancePercentage: new Big('0'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('3000'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2022-01-01',
|
||||||
|
grossPerformance: null,
|
||||||
|
grossPerformancePercentage: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: null,
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
marketPrice: null,
|
||||||
|
marketPriceInBaseCurrency: 3000,
|
||||||
|
netPerformance: null,
|
||||||
|
netPerformancePercentage: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: null,
|
||||||
|
netPerformanceWithCurrencyEffect: null,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('0'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,142 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with MSFT buy', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-09-16'),
|
||||||
|
fee: 19,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 298.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-16'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: 0.62
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2023-07-10')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toMatchObject({
|
||||||
|
errors: [],
|
||||||
|
hasErrors: false,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('298.58'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0.62'),
|
||||||
|
dividendInBaseCurrency: new Big('0.62'),
|
||||||
|
fee: new Big('19'),
|
||||||
|
firstBuyDate: '2021-09-16',
|
||||||
|
investment: new Big('298.58'),
|
||||||
|
investmentWithCurrencyEffect: new Big('298.58'),
|
||||||
|
marketPrice: 331.83,
|
||||||
|
marketPriceInBaseCurrency: 331.83,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'MSFT',
|
||||||
|
tags: [],
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('19'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('298.58'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('298.58'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it('with no orders', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities: [],
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = subDays(new Date(Date.now()), 10);
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({ start });
|
||||||
|
|
||||||
|
const portfolioSnapshot =
|
||||||
|
await portfolioCalculator.computeSnapshot(start);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,180 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-03-07'),
|
||||||
|
fee: 1.3,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 2.95,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 85.73
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2022-03-07')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('87.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.15113417083448194384'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.12184460284330327256'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('75.80'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('4.25'),
|
||||||
|
firstBuyDate: '2022-03-07',
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.15113417083448194384'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||||
|
investment: new Big('75.80'),
|
||||||
|
investmentWithCurrencyEffect: new Big('75.80'),
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.12184460284330327256'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||||
|
marketPrice: 87.8,
|
||||||
|
marketPriceInBaseCurrency: 87.8,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'145.10285714285714285714'
|
||||||
|
),
|
||||||
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('87.8')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('4.25'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('75.80'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('75.80'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('75.8') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: 151.6 },
|
||||||
|
{ date: '2022-04-01', investment: -75.8 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,204 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with NOVN.SW buy and sell', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-03-07'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Novartis AG',
|
||||||
|
symbol: 'NOVN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 85.73
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2022-03-07')
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(chartData[0]).toEqual({
|
||||||
|
date: '2022-03-07',
|
||||||
|
investmentValueWithCurrencyEffect: 151.6,
|
||||||
|
netPerformance: 0,
|
||||||
|
netPerformanceInPercentage: 0,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||||
|
netPerformanceWithCurrencyEffect: 0,
|
||||||
|
totalInvestment: 151.6,
|
||||||
|
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||||
|
value: 151.6,
|
||||||
|
valueWithCurrencyEffect: 151.6
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chartData[chartData.length - 1]).toEqual({
|
||||||
|
date: '2022-04-11',
|
||||||
|
investmentValueWithCurrencyEffect: 0,
|
||||||
|
netPerformance: 19.86,
|
||||||
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||||
|
netPerformanceWithCurrencyEffect: 19.86,
|
||||||
|
totalInvestment: 0,
|
||||||
|
totalInvestmentValueWithCurrencyEffect: 0,
|
||||||
|
value: 0,
|
||||||
|
valueWithCurrencyEffect: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(portfolioSnapshot).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('19.86'),
|
||||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('19.86'),
|
||||||
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2022-03-07',
|
||||||
|
grossPerformance: new Big('19.86'),
|
||||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformance: new Big('19.86'),
|
||||||
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
|
marketPrice: 87.8,
|
||||||
|
marketPriceInBaseCurrency: 87.8,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
tags: [],
|
||||||
|
timeWeightedInvestment: new Big('151.6'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||||
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalFeesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInterestWithCurrencyEffect: new Big('0'),
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||||
|
totalValuablesWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: 151.6 },
|
||||||
|
{ date: '2022-04-01', investment: -151.6 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user