Compare commits
656 Commits
Author | SHA1 | Date | |
---|---|---|---|
ab59eb5c92 | |||
1132dc9bdd | |||
2d70b18593 | |||
b6ea7d23fa | |||
95382581f1 | |||
551b83a6e3 | |||
895c4fe299 | |||
ccb1bf881e | |||
7788b5a987 | |||
47fd029e0c | |||
458ee159e1 | |||
dfacbed66d | |||
22d63c6102 | |||
212aa6a63b | |||
bed9ae916c | |||
b6ad362850 | |||
ab86dd5318 | |||
dba73d80a3 | |||
92fb05320a | |||
73d62bb51f | |||
127b7d4f25 | |||
e79d607ab8 | |||
5f7d083f7c | |||
15857118fe | |||
ff91ed21df | |||
9241c04d5a | |||
5d4e2fba8c | |||
6c57609db8 | |||
b31bbbe2d1 | |||
7d308917dd | |||
4e7d93db13 | |||
45340b581f | |||
6f8fe45fc2 | |||
34d9ceb009 | |||
2b97bbd05d | |||
3bf7ac76a0 | |||
71892e67b2 | |||
6e2885ed20 | |||
07c0e5a612 | |||
719bbe156e | |||
b51255a543 | |||
50dbbf0569 | |||
ca2e748c56 | |||
5c480109d5 | |||
6152ff4b44 | |||
c6641fde36 | |||
4ae7e9fcbe | |||
c10ae431a2 | |||
883e30e451 | |||
f1f4f6247d | |||
82fe1de1a7 | |||
371c999fbc | |||
26b9660e11 | |||
ca7717f9c5 | |||
6f3cce1c5f | |||
efdc9b387f | |||
d7b579e3e8 | |||
b8533050b0 | |||
1b81409b35 | |||
8cd6c34ed8 | |||
0c68474802 | |||
34997f91db | |||
084467ee9a | |||
af47889d65 | |||
51203ec96e | |||
a2277dea2c | |||
debd233c32 | |||
f1eeee0525 | |||
5ffc39c32f | |||
a668a66e84 | |||
0581b8b9ec | |||
63a61fb492 | |||
5788c6474e | |||
5529fdc0ee | |||
88a9b518f6 | |||
98de2355c4 | |||
b41eb60348 | |||
0edebe30e1 | |||
e3abe4feee | |||
50391e199a | |||
a33f8d5bed | |||
636be8441e | |||
654dc2ba32 | |||
458ef169f4 | |||
5bb01bb03c | |||
43e9528d8c | |||
522c54c9b4 | |||
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 | |||
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e |
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,7 +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>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
|
||||||
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,
|
||||||
|
1197
CHANGELOG.md
1197
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,16 +20,28 @@
|
|||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `yarn database:push`
|
||||||
|
@ -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" ]
|
||||||
|
89
README.md
89
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)
|
||||||
@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance 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
|
||||||
@ -85,19 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | 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
|
||||||
|
|
||||||
@ -113,7 +117,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
|
||||||
@ -121,8 +125,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
|
||||||
@ -133,12 +137,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.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [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
|
||||||
|
|
||||||
@ -148,13 +152,14 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
- [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`)
|
||||||
@ -163,7 +168,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
|
|
||||||
#### 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
|
||||||
|
|
||||||
@ -199,7 +204,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>`.
|
||||||
|
|
||||||
@ -228,18 +233,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
|
||||||
|
|
||||||
@ -270,12 +275,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,6 +7,10 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsUUID()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AccessPermission, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
permissions?: AccessPermission[];
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,84 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Body,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AccountBalance } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
|
@Controller('account-balance')
|
||||||
|
export class AccountBalanceController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccountBalance)
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async createAccountBalance(
|
||||||
|
@Body() data: CreateAccountBalanceDto
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const account = await this.accountService.account({
|
||||||
|
id_userId: {
|
||||||
|
id: data.accountId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
|
accountId: account.id,
|
||||||
|
balance: data.balance,
|
||||||
|
date: data.date,
|
||||||
|
userId: account.userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
|
@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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
16
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
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, AccountService]
|
||||||
|
})
|
||||||
|
export class AccountBalanceModule {}
|
121
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
121
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
|
@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 createOrUpdateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
balance,
|
||||||
|
date,
|
||||||
|
userId
|
||||||
|
}: CreateAccountBalanceDto & {
|
||||||
|
userId: string;
|
||||||
|
}): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.upsert({
|
||||||
|
create: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
date: resetHours(parseISO(date)),
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
accountId_date: {
|
||||||
|
accountId,
|
||||||
|
date: resetHours(parseISO(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccountBalanceDto {
|
||||||
|
@IsUUID()
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
date: string;
|
||||||
|
}
|
@ -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,12 @@
|
|||||||
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 { parseISO } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -20,10 +22,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 +109,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 +218,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({
|
||||||
@ -243,17 +243,11 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.accountBalanceService.createAccountBalance({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
date,
|
accountId,
|
||||||
Account: {
|
userId,
|
||||||
connect: {
|
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||||
id_userId: {
|
date: date.toISOString()
|
||||||
userId,
|
|
||||||
id: accountId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,9 +10,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
|
||||||
accountType: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@ -23,7 +20,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TransferBalanceDto {
|
||||||
|
@IsString()
|
||||||
|
accountIdFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
accountIdTo: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
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,9 +10,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
|
||||||
accountType: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
@ -23,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,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
|
||||||
@ -8,7 +9,9 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
PROPERTY_CURRENCIES
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
@ -19,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';
|
||||||
|
|
||||||
@ -38,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 }
|
||||||
]);
|
]);
|
||||||
@ -52,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) {
|
||||||
@ -82,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,
|
||||||
@ -95,7 +123,8 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -143,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 }];
|
||||||
|
|
||||||
@ -169,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 },
|
||||||
@ -183,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
|
||||||
@ -209,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,
|
||||||
@ -272,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([
|
||||||
{
|
{
|
||||||
@ -305,7 +386,9 @@ export class AdminService {
|
|||||||
response = await this.propertyService.delete({ key });
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||||
|
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||||
|
} else if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -377,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
|
||||||
@ -393,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';
|
||||||
@ -39,6 +40,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
|
import { TagModule } from './tag/tag.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -51,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
|
||||||
@ -72,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({
|
||||||
@ -101,6 +105,7 @@ import { UserModule } from './user/user.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -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,16 +8,19 @@ 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,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -32,47 +38,10 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
|
||||||
return {
|
|
||||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('startDateString') startDateString: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const startDate = new Date(startDateString);
|
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
|
||||||
dataSource,
|
|
||||||
startDate,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
||||||
@ -94,4 +63,66 @@ export class BenchmarkController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':dataSource/:symbol')
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteBenchmark(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
return {
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('startDateString') startDateString: string,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const { endDate, startDate } = getInterval(
|
||||||
|
dateRange,
|
||||||
|
new Date(startDateString)
|
||||||
|
);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
endDate,
|
||||||
|
startDate,
|
||||||
|
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({
|
||||||
@ -245,7 +377,52 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
public async deleteBenchmark({
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId !== assetProfile.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMarketCondition(
|
||||||
|
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 = (
|
||||||
@ -26,18 +31,8 @@ export class ExportService {
|
|||||||
where: { userId }
|
where: { userId }
|
||||||
})
|
})
|
||||||
).map(
|
).map(
|
||||||
({
|
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||||
accountType,
|
|
||||||
balance,
|
|
||||||
comment,
|
|
||||||
currency,
|
|
||||||
id,
|
|
||||||
isExcluded,
|
|
||||||
name,
|
|
||||||
platformId
|
|
||||||
}) => {
|
|
||||||
return {
|
return {
|
||||||
accountType,
|
|
||||||
balance,
|
balance,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
currency,
|
||||||
@ -49,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) {
|
||||||
@ -87,10 +86,19 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol:
|
||||||
|
type === 'FEE' ||
|
||||||
|
type === 'INTEREST' ||
|
||||||
|
type === 'ITEM' ||
|
||||||
|
type === 'LIABILITY'
|
||||||
|
? SymbolProfile.name
|
||||||
|
: 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';
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ import {
|
|||||||
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 {
|
||||||
@ -29,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';
|
||||||
@ -54,21 +54,12 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = (
|
const platforms = await this.platformService.getPlatforms({
|
||||||
await this.platformService.getPlatforms({
|
orderBy: { name: 'asc' }
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
})
|
|
||||||
).map(({ id, name }) => {
|
|
||||||
return { id, name };
|
|
||||||
});
|
});
|
||||||
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(
|
||||||
@ -107,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 =
|
||||||
@ -138,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()
|
||||||
@ -168,16 +154,24 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, 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`,
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' }
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
return pull_count;
|
return pull_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - DockerHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -185,17 +179,26 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio');
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
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');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -203,16 +206,24 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, 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`,
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' }
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -323,24 +334,31 @@ export class InfoService {
|
|||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
)) as string;
|
)) as string;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { data } = await got(
|
const { data } = await got(
|
||||||
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
subDays(new Date(), 90),
|
subDays(new Date(), 90),
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.configurationService.get(
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
'BETTER_UPTIME_API_KEY'
|
'API_KEY_BETTER_UPTIME'
|
||||||
)}`
|
)}`
|
||||||
}
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
).json<any>();
|
).json<any>();
|
||||||
|
|
||||||
return data.attributes.availability / 100;
|
return data.attributes.availability / 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - Better Stack');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -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,5 +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 { 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';
|
||||||
@ -8,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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -41,10 +44,18 @@ export class LogoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBuffer(aUrl: string) {
|
private getBuffer(aUrl: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, 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`,
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' }
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
).buffer();
|
).buffer();
|
||||||
}
|
}
|
||||||
|
@ -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,19 @@ 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('tags') filterByTags?: string
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -97,32 +101,41 @@ 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,
|
||||||
|
take: isNaN(take) ? undefined : take,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
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({
|
||||||
@ -131,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
|
||||||
},
|
},
|
||||||
@ -147,33 +160,34 @@ export class OrderController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!order.isDraft) {
|
if (data.dataSource && !order.isDraft) {
|
||||||
// Gather symbol data in the background, if not draft
|
// Gather symbol data in the background, if data source is set
|
||||||
this.dataGatheringService.gatherSymbols([
|
// (not MANUAL) and not draft
|
||||||
{
|
this.dataGatheringService.gatherSymbols({
|
||||||
dataSource: data.dataSource,
|
dataGatheringItems: [
|
||||||
date: order.date,
|
{
|
||||||
symbol: data.symbol
|
dataSource: data.dataSource,
|
||||||
}
|
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
|
||||||
@ -183,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,15 +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 (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
@ -108,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;
|
||||||
@ -118,20 +90,23 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.addJobToQueue({
|
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||||
data: {
|
this.dataGatheringService.addJobToQueue({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
data: {
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
},
|
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
|
||||||
opts: {
|
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
|
||||||
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
|
||||||
})
|
},
|
||||||
}
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
});
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
}),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
@ -141,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;
|
||||||
@ -150,10 +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 === 'LIABILITY'
|
? false
|
||||||
? false
|
: isAfter(data.date as Date, endOfToday());
|
||||||
: isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
@ -174,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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -197,7 +170,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,23 +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,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
startDate,
|
||||||
|
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;
|
||||||
types?: TypeOfOrder[];
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
startDate?: Date;
|
||||||
|
take?: number;
|
||||||
|
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,
|
||||||
@ -288,18 +299,26 @@ 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,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -311,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({
|
||||||
@ -353,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;
|
||||||
}
|
}
|
||||||
@ -368,8 +416,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
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;
|
||||||
|
|
||||||
@ -377,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;
|
||||||
@ -413,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,48 +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
|
||||||
});
|
});
|
||||||
@ -87,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';
|
||||||
|
|
||||||
@ -6,6 +7,18 @@ import { Platform, Prisma } from '@prisma/client';
|
|||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
public async getPlatform(
|
public async getPlatform(
|
||||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
@ -56,12 +69,6 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
|
||||||
return this.prismaService.platform.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updatePlatform({
|
public async updatePlatform({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -74,10 +81,4 @@ export class PlatformService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlatform(
|
|
||||||
where: Prisma.PlatformWhereUniqueInput
|
|
||||||
): Promise<Platform> {
|
|
||||||
return this.prismaService.platform.delete({ where });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user