Compare commits
685 Commits
Author | SHA1 | Date | |
---|---|---|---|
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e | |||
0ec50819f5 | |||
c9abe818bc | |||
bfa32537a8 | |||
cef15afab8 | |||
1b9587c454 | |||
de76b0d8c3 | |||
e62989c981 | |||
d6b71e6314 | |||
8c59bfd6d7 | |||
f32df73256 | |||
9d03a8002c | |||
3c36ca29af | |||
efed7e3c2b | |||
b09d3cea95 | |||
eabd2f3934 | |||
cc184c2827 | |||
436f791fa4 | |||
e935a57dec | |||
203909d917 | |||
eed4f57f30 | |||
7878036bac | |||
75d140b436 | |||
a79f31b006 | |||
45cfd61dbb | |||
7fcfca952e | |||
279f16cc67 | |||
e7b1d8a5d3 | |||
1b2f8e5586 | |||
e4468252c6 | |||
ad3ebd42bb | |||
55b03733f4 | |||
0000317041 | |||
e5f2a3865d | |||
c61561664f | |||
a7d8a63ab8 | |||
5c51c1e825 | |||
3a67bf9bb4 | |||
f7597c213d | |||
2e7f46ad78 | |||
cfffb99f52 | |||
69ac3408f1 | |||
e1806b4bd8 | |||
6aae0cc1e4 | |||
5d8a50a80d | |||
662231e830 | |||
4d84459b5b | |||
efba7429c1 | |||
9cae5a3e79 | |||
c2ed0a436f | |||
8486c02575 | |||
5122ef3456 | |||
579b86665e | |||
52b3ad6dc3 | |||
bf9b60aa74 | |||
6cd51fb044 | |||
271001f523 | |||
a7e513a6d1 | |||
b5f256be95 | |||
a834ef6b4c | |||
e5bd0d1bfa | |||
7fa6eda45d | |||
f47e4d3b04 | |||
0300c6f3b7 | |||
4865c45fd4 | |||
2beceb36cf | |||
cd64601482 | |||
efac39eb51 | |||
4da8a547ca | |||
9e8a9e4670 | |||
bb99141e9c | |||
d147c2313f | |||
0878941c4f | |||
69a9e77820 | |||
104cca069f | |||
7ad58b1a62 | |||
e88dbb0181 | |||
152fd4fdf8 | |||
6b022b8de8 | |||
7ab699e5fe | |||
a7e5a316be | |||
3f2d3a2da9 | |||
0208bd0923 | |||
aeba6e1f03 | |||
1b899da9ff | |||
90a7a84ac5 | |||
fc8e23a9c8 | |||
f3c8ec27cb | |||
38474f54b0 | |||
18d25fb6c2 | |||
a850e8ca22 | |||
b5f565c054 | |||
aa6d0a4533 | |||
25e9028a41 | |||
925d38703e | |||
158bb00b8a | |||
b17111e6f1 | |||
c4765e31cd | |||
d321d56dee | |||
07dd22f7fe | |||
eb4d088a80 | |||
0509f0101f | |||
8818e09be8 | |||
d97fe4da9c | |||
b20fa55b79 | |||
dd7a6f1562 | |||
15357bd5b5 | |||
52c7adc266 | |||
1ae8970045 | |||
7c4c047140 | |||
527f7e4faf | |||
50160eb9dc | |||
58dff8a1e0 | |||
2cd41615b2 | |||
66d5793528 | |||
e8d65e1c85 | |||
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f | |||
629f002074 | |||
7c65cf6ddd | |||
c38ebec3be | |||
2b8ab26e7e | |||
60f52bb209 | |||
616d168a7c | |||
b13e4425d3 | |||
1424236c48 | |||
2a605f850d | |||
88ffbfead0 | |||
5f4a8d505b | |||
e87b93f19c | |||
49dcade964 | |||
7cd65eed39 | |||
a51b210f79 | |||
285f2220f3 | |||
d72123246d | |||
3a78d6c3f1 | |||
d5e3ff5717 | |||
2efb331370 | |||
f521fe99c5 | |||
42306530b8 | |||
68c9d1b266 | |||
1ce90a0c06 | |||
50f6d154e5 | |||
e4c44faee4 | |||
5209f82cca | |||
292d345ce0 | |||
d58400788a | |||
7ff61ae839 | |||
b5b7af7741 | |||
de3e0fad83 | |||
8c8273c4d4 | |||
b406bcd17d | |||
fb496431e8 | |||
441b251536 | |||
1dbb5db611 | |||
8567efcd89 | |||
1cda5dcc0a | |||
3fb01c6dcf | |||
6a764fe893 | |||
d2b75a244c | |||
3611684f17 | |||
4b74be50da | |||
0d338bb083 | |||
b0d708fb82 | |||
be14458437 | |||
5978ddb80f | |||
18638dd1b7 | |||
81db3852e6 | |||
af27781234 | |||
608e7a774d | |||
ed15eb76fd | |||
39905e5046 | |||
7cd3f235df | |||
3b4f8c69bb | |||
c9bdf46b2b | |||
4169de580b | |||
3317fe7c46 | |||
c8f6fdbaa3 | |||
d95fc82f95 | |||
31c949f9d2 | |||
f68f40fcc6 | |||
9623a363ed | |||
2d42549967 | |||
c934c5088b | |||
678b3cc57e | |||
cd5eb64a4c | |||
fc1507de4f | |||
d147a66dcd | |||
33fd1282e5 | |||
693ff9d3ea | |||
21e87a0055 | |||
43426c9b01 | |||
3b4da72ea3 | |||
8d8e55fd0b | |||
ca18621ce8 | |||
b8574d24b2 | |||
6d12c27f9c | |||
c2c5326049 | |||
2a1339b61e | |||
c8a2579624 | |||
832ae063df | |||
b5e026934f | |||
901c997908 | |||
3b6e0b20e2 | |||
e449d51c3c | |||
f72d31bab3 | |||
4c893c4dcc | |||
ffb11cd10e | |||
d424b7731e | |||
6043c87481 | |||
fca0a688b6 | |||
5c6cc4fed5 | |||
64a7d38ff9 | |||
68d0d39161 | |||
233a8a8a18 | |||
190779ee35 | |||
6ef8121561 | |||
58bf57d1e6 | |||
71c5412dd5 | |||
ae85398c3d | |||
048900d01b | |||
074b09b543 | |||
f9e04022f4 | |||
8fd1fbd44a | |||
0fb33ae71c | |||
3a35d72ec2 | |||
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 | |||
21b4b0ef24 | |||
4f8fe83a16 | |||
980ad1028c | |||
0d5bc3f51b | |||
aece76d98f | |||
fc4bb71184 | |||
20bc7ef99c | |||
7a733ae49b | |||
376ce88492 | |||
c4d83aabe7 | |||
d4e2cec77e | |||
75db7bf79a | |||
3ad99c9991 | |||
00e402d286 | |||
4ac0484025 | |||
75d61bff6d | |||
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 | |||
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 | |||
7b5454e7de | |||
30835ced88 | |||
8897f32bc5 | |||
abaa6b5f27 | |||
2060fcaf0b | |||
fd2408dd62 | |||
31cca024f1 | |||
b535122945 | |||
5113e4e3ad | |||
35e039748f | |||
c6b9e0aa5b | |||
b250491ca5 | |||
61e501c659 | |||
c0f19d56ec | |||
8e2b235b1f | |||
c3407e9b34 | |||
74193e4ee2 | |||
3fe8f9c882 | |||
d130efad47 | |||
109f0ebd70 | |||
069ddcc6b2 | |||
f7bf6e652b | |||
eb059a024a | |||
ad88acff1c | |||
1ff736537c | |||
1fa65e1efd | |||
df6bb489c2 | |||
928a13310d | |||
2384861953 | |||
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 | |||
1071f446a8 | |||
03b050d1ac | |||
58eeff7001 | |||
76fb8825e4 | |||
0f9d142afe | |||
bd33855a27 | |||
5329e45e2c | |||
e990ecd12c | |||
a4fcf64f13 | |||
557e3a0676 | |||
2abe399ebd | |||
74fe90906a | |||
4cb9a3b142 | |||
0da9368e0c | |||
d2f8e3d645 | |||
5263fba64e | |||
e3689c48f8 | |||
787efdb33b | |||
e63578d8ce | |||
7cf0cdc4ce | |||
14a0eeab29 | |||
6774c48dff | |||
565947e752 | |||
2cc7c6fa1c | |||
023a7147e2 | |||
a96e89a86e | |||
b9c9443899 | |||
f1e06347d3 | |||
697e92f818 | |||
b678998801 | |||
de53cf1884 | |||
bbe30218bd | |||
15dda886a0 | |||
34d4212f55 | |||
f7060230b7 | |||
0fdafcb7e4 | |||
e79be9f2d6 | |||
69088b93a6 | |||
c3768a882d | |||
3498ed8549 | |||
c07c300fef | |||
c62a5af9eb | |||
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a | |||
f1483569a2 | |||
5391b88c42 | |||
2b63f7e707 | |||
d5c96d1cb7 | |||
1a4dc51825 | |||
d094bae7de | |||
57bf10e7e7 | |||
c1d460cead | |||
dfa67b275c | |||
80862e5c2a | |||
904d4db219 | |||
10f13eec48 | |||
ea3a9d3b79 | |||
e55b05fe3d | |||
32dd76be5f | |||
ff9b6bb4df | |||
5be95b7b63 | |||
b3e07c8446 | |||
eb9cece4e4 | |||
b331f5f04d | |||
34cbdd7c2a | |||
57314d62ee | |||
40380346e6 | |||
5622c4cf7e | |||
21173bed21 | |||
16dd8f7652 | |||
ce6b5fb7cb | |||
f6f62db830 | |||
01103f3db4 | |||
e9e9f1a124 | |||
751256f158 | |||
c2a1cbd20f | |||
04044f8720 | |||
4dc76817ce | |||
1f0bd5a7db | |||
b6cd007ad4 | |||
b4bc72c6f9 | |||
899fa0370e | |||
da27504aa1 | |||
b7bbc029ac | |||
c61a415fb2 | |||
8ff811ed28 | |||
9a2ea0a4ed | |||
bad9d17c44 | |||
ea89ca5734 | |||
8f61f7c169 | |||
edca05f542 | |||
283f054ee2 | |||
e9a46cb224 | |||
4a75c6d483 | |||
bbe9183fb0 | |||
1b03ddc586 | |||
beb12637ce | |||
20358d9105 | |||
0e4c39d145 | |||
83ebacbb06 | |||
7c58c5fb7f | |||
f3271ab1ff | |||
9f597cbff1 | |||
90efc2ac51 | |||
056b318d86 | |||
82ede2fe32 | |||
8ae041faa0 | |||
bd4608e521 | |||
0d8362ca8f | |||
638ae3f7fa | |||
6e7cf0380b | |||
ec2ecab751 | |||
598fe41b8c | |||
ba7c98d325 | |||
65e062ad26 | |||
8526b5a027 | |||
f1feb04f29 | |||
500e09d95a | |||
aef91d3e30 | |||
70723f8d5f | |||
6cfd052781 | |||
23f2ac472e | |||
d5ba624403 | |||
9b49ed77f7 | |||
08405d14d5 | |||
56b169e1c4 | |||
67f2b326f3 | |||
3d3a6c1204 | |||
bfc8f87d88 | |||
957200854c | |||
6575440877 |
8
.env
8
.env
@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
|||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
# POSTGRES
|
# POSTGRES
|
||||||
POSTGRES_DB=ghostfolio-db
|
POSTGRES_DB=ghostfolio-db
|
||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=password
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
JWT_SECRET_KEY=123456
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
PORT=3333
|
|
||||||
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
custom: ['https://www.buymeacoffee.com/ghostfolio']
|
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: '[BUG]'
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
|
**Bug Description**
|
||||||
|
|
||||||
|
<!-- A clear and concise description of what the bug is. -->
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
|
||||||
|
<!-- Steps to reproduce the behavior -->
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
|
||||||
|
<!-- A clear and concise description of what you expected to happen. -->
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
|
||||||
|
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||||
|
|
||||||
|
**Logs**
|
||||||
|
|
||||||
|
<!-- If applicable, add logs to help explain your problem. -->
|
||||||
|
|
||||||
|
**Environment**
|
||||||
|
|
||||||
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Browser
|
||||||
|
- OS
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
|
||||||
|
<!-- Add any other context about the problem here. -->
|
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: Build code
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node_version:
|
||||||
|
- 18
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node_version }}
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
- name: Execute tests
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build:all
|
49
.github/workflows/docker-image.yml
vendored
Normal file
49
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Docker image CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ghostfolio/ghostfolio
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@ -24,15 +25,16 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"attributeGroups": [
|
||||||
|
"$ANGULAR_ELEMENT_REF",
|
||||||
|
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||||
|
"$DEFAULT",
|
||||||
|
"$ANGULAR_INPUT",
|
||||||
|
"$ANGULAR_TWO_WAY_BINDING",
|
||||||
|
"$ANGULAR_OUTPUT"
|
||||||
|
],
|
||||||
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
30
.travis.yml
30
.travis.yml
@ -1,30 +0,0 @@
|
|||||||
language: node_js
|
|
||||||
git:
|
|
||||||
depth: false
|
|
||||||
node_js:
|
|
||||||
- 14
|
|
||||||
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
cache: yarn
|
|
||||||
|
|
||||||
if: (type = pull_request) OR (tag IS present)
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- stage: Install dependencies
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn --frozen-lockfile
|
|
||||||
- stage: Check formatting
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn format:check
|
|
||||||
- stage: Execute tests
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn test
|
|
||||||
- stage: Build application
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn build:all
|
|
||||||
- stage: Build and publish docker image
|
|
||||||
if: tag IS present
|
|
||||||
script: ./publish-docker-image.sh
|
|
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -5,11 +5,11 @@
|
|||||||
"name": "Debug Jest File",
|
"name": "Debug Jest File",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--codeCoverage=false",
|
"--codeCoverage=false",
|
||||||
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
|
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "internalConsole"
|
"console": "internalConsole"
|
||||||
|
1422
CHANGELOG.md
1422
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
25
Dockerfile
25
Dockerfile
@ -1,7 +1,6 @@
|
|||||||
FROM node:14-alpine as builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
|
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
# Only add basic files without the application itself to avoid rebuilding
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
@ -10,20 +9,26 @@ COPY ./CHANGELOG.md CHANGELOG.md
|
|||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./.yarnrc .yarnrc
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apk add --no-cache python3 g++ make openssl
|
RUN apt update && apt install -y \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
openssl \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
RUN node decorate-angular-cli.js
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
COPY ./angular.json angular.json
|
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.js jest.preset.js
|
COPY ./jest.preset.js jest.preset.js
|
||||||
COPY ./jest.config.js jest.config.js
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
@ -45,8 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:14-alpine
|
FROM node:18-slim
|
||||||
|
RUN apt update && apt install -y \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "node", "main" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
185
README.md
185
README.md
@ -1,40 +1,33 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://ghostfol.io">
|
|
||||||
<img
|
|
||||||
alt="Ghostfolio Logo"
|
|
||||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
|
||||||
width="100"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||||
<p>
|
|
||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
[**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) |
|
||||||
<p>
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
<a href="#contributing">
|
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
[](#contributing)
|
||||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
|
||||||
|
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/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.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -47,20 +40,26 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2021
|
- 🙅 saying no to spreadsheets
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance 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
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@ -74,89 +73,109 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Run with Docker (self-hosting)
|
## Self-hosting
|
||||||
|
|
||||||
### Prerequisites
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
<div align="center">
|
||||||
- A local copy of this Git repository (clone)
|
|
||||||
|
|
||||||
### a. Run environment
|
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Supported Environment Variables
|
||||||
|
|
||||||
|
| Name | Default Value | Description |
|
||||||
|
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
|
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
|
||||||
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
|
||||||
|
### Run with Docker Compose
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- Basic knowledge of Docker
|
||||||
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- Local copy of this Git repository (clone)
|
||||||
|
|
||||||
|
#### a. Run environment
|
||||||
|
|
||||||
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 -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### b. Build and run environment
|
|
||||||
|
|
||||||
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 -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch Historical Data
|
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
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`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### 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 -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`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
|
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `yarn build:dev` to build the source code including the assets
|
||||||
|
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 and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
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`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
<ol type="a">
|
#### Debug
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
</ol>
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
@ -174,7 +193,17 @@ yarn database:push
|
|||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
|
|
||||||
## Public API (experimental)
|
## Public API
|
||||||
|
|
||||||
|
### Authorization: Bearer Token
|
||||||
|
|
||||||
|
Set the header for each request as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Authorization": "Bearer eyJh..."
|
||||||
|
```
|
||||||
|
|
||||||
|
You can get the _Bearer Token_ via `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>`.
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
@ -182,14 +211,6 @@ Run `yarn test`
|
|||||||
|
|
||||||
`POST http://localhost:3333/api/v1/import`
|
`POST http://localhost:3333/api/v1/import`
|
||||||
|
|
||||||
#### Authorization: Bearer Token
|
|
||||||
|
|
||||||
Set the header as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
"Authorization": "Bearer eyJh..."
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Body
|
#### Body
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -201,7 +222,7 @@ Set the header as follows:
|
|||||||
"date": "2021-09-15T00:00:00.000Z",
|
"date": "2021-09-15T00:00:00.000Z",
|
||||||
"fee": 19,
|
"fee": 19,
|
||||||
"quantity": 5,
|
"quantity": 5,
|
||||||
"symbol": "MSFT"
|
"symbol": "MSFT",
|
||||||
"type": "BUY",
|
"type": "BUY",
|
||||||
"unitPrice": 298.58
|
"unitPrice": 298.58
|
||||||
}
|
}
|
||||||
@ -240,14 +261,20 @@ Set the header as follows:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Community Projects
|
||||||
|
|
||||||
|
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
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 channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). 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).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2023 [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).
|
||||||
|
325
angular.json
325
angular.json
@ -1,325 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"projects": {
|
|
||||||
"api": {
|
|
||||||
"root": "apps/api",
|
|
||||||
"sourceRoot": "apps/api/src",
|
|
||||||
"projectType": "application",
|
|
||||||
"prefix": "api",
|
|
||||||
"schematics": {},
|
|
||||||
"architect": {
|
|
||||||
"build": {
|
|
||||||
"builder": "@nrwl/node:webpack",
|
|
||||||
"options": {
|
|
||||||
"outputPath": "dist/apps/api",
|
|
||||||
"main": "apps/api/src/main.ts",
|
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
|
||||||
"assets": ["apps/api/src/assets"]
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"generatePackageJson": true,
|
|
||||||
"optimization": true,
|
|
||||||
"extractLicenses": true,
|
|
||||||
"inspect": false,
|
|
||||||
"fileReplacements": [
|
|
||||||
{
|
|
||||||
"replace": "apps/api/src/environments/environment.ts",
|
|
||||||
"with": "apps/api/src/environments/environment.prod.ts"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"outputs": ["{options.outputPath}"]
|
|
||||||
},
|
|
||||||
"serve": {
|
|
||||||
"builder": "@nrwl/node:node",
|
|
||||||
"options": {
|
|
||||||
"buildTarget": "api:build"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@nrwl/jest:jest",
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "apps/api/jest.config.js",
|
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
|
||||||
"outputs": ["coverage/apps/api"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"projectType": "application",
|
|
||||||
"schematics": {
|
|
||||||
"@schematics/angular:component": {
|
|
||||||
"style": "scss"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "apps/client",
|
|
||||||
"sourceRoot": "apps/client/src",
|
|
||||||
"prefix": "gf",
|
|
||||||
"architect": {
|
|
||||||
"build": {
|
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
|
||||||
"options": {
|
|
||||||
"outputPath": "dist/apps/client",
|
|
||||||
"index": "apps/client/src/index.html",
|
|
||||||
"main": "apps/client/src/main.ts",
|
|
||||||
"polyfills": "apps/client/src/polyfills.ts",
|
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
|
||||||
"assets": [
|
|
||||||
"apps/client/src/assets",
|
|
||||||
{
|
|
||||||
"glob": "assetlinks.json",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./.well-known"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "CHANGELOG.md",
|
|
||||||
"input": "",
|
|
||||||
"output": "./assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "LICENSE",
|
|
||||||
"input": "",
|
|
||||||
"output": "./assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "robots.txt",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "sitemap.xml",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
|
||||||
"output": "./ionicons"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*.js",
|
|
||||||
"input": "node_modules/ionicons/dist/",
|
|
||||||
"output": "./"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
|
||||||
"scripts": ["node_modules/marked/lib/marked.js"],
|
|
||||||
"vendorChunk": true,
|
|
||||||
"extractLicenses": false,
|
|
||||||
"buildOptimizer": false,
|
|
||||||
"sourceMap": true,
|
|
||||||
"optimization": false,
|
|
||||||
"namedChunks": true
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"fileReplacements": [
|
|
||||||
{
|
|
||||||
"replace": "apps/client/src/environments/environment.ts",
|
|
||||||
"with": "apps/client/src/environments/environment.prod.ts"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"optimization": true,
|
|
||||||
"outputHashing": "all",
|
|
||||||
"sourceMap": false,
|
|
||||||
"namedChunks": false,
|
|
||||||
"extractLicenses": true,
|
|
||||||
"vendorChunk": false,
|
|
||||||
"buildOptimizer": true,
|
|
||||||
"budgets": [
|
|
||||||
{
|
|
||||||
"type": "initial",
|
|
||||||
"maximumWarning": "2mb",
|
|
||||||
"maximumError": "5mb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "anyComponentStyle",
|
|
||||||
"maximumWarning": "6kb",
|
|
||||||
"maximumError": "10kb"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"outputs": ["{options.outputPath}"],
|
|
||||||
"defaultConfiguration": ""
|
|
||||||
},
|
|
||||||
"serve": {
|
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
|
||||||
"options": {
|
|
||||||
"browserTarget": "client:build",
|
|
||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"browserTarget": "client:build:production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"extract-i18n": {
|
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
|
||||||
"options": {
|
|
||||||
"browserTarget": "client:build"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@nrwl/jest:jest",
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "apps/client/jest.config.js",
|
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
|
||||||
"outputs": ["coverage/apps/client"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"client-e2e": {
|
|
||||||
"root": "apps/client-e2e",
|
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
|
||||||
"projectType": "application",
|
|
||||||
"architect": {
|
|
||||||
"e2e": {
|
|
||||||
"builder": "@nrwl/cypress:cypress",
|
|
||||||
"options": {
|
|
||||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
|
||||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
|
||||||
"devServerTarget": "client:serve"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"devServerTarget": "client:serve:production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": [],
|
|
||||||
"implicitDependencies": ["client"]
|
|
||||||
},
|
|
||||||
"common": {
|
|
||||||
"root": "libs/common",
|
|
||||||
"sourceRoot": "libs/common/src",
|
|
||||||
"projectType": "library",
|
|
||||||
"architect": {
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@nrwl/jest:jest",
|
|
||||||
"outputs": ["coverage/libs/common"],
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "libs/common/jest.config.js",
|
|
||||||
"passWithNoTests": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"projectType": "library",
|
|
||||||
"schematics": {
|
|
||||||
"@schematics/angular:component": {
|
|
||||||
"style": "scss"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "libs/ui",
|
|
||||||
"sourceRoot": "libs/ui/src",
|
|
||||||
"prefix": "gf",
|
|
||||||
"architect": {
|
|
||||||
"test": {
|
|
||||||
"builder": "@nrwl/jest:jest",
|
|
||||||
"outputs": ["coverage/libs/ui"],
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "libs/ui/jest.config.js",
|
|
||||||
"passWithNoTests": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storybook": {
|
|
||||||
"builder": "@nrwl/storybook:storybook",
|
|
||||||
"options": {
|
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"port": 4400,
|
|
||||||
"config": {
|
|
||||||
"configFolder": "libs/ui/.storybook"
|
|
||||||
},
|
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"ci": {
|
|
||||||
"quiet": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"build-storybook": {
|
|
||||||
"builder": "@nrwl/storybook:build",
|
|
||||||
"outputs": ["{options.outputPath}"],
|
|
||||||
"options": {
|
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"outputPath": "dist/storybook/ui",
|
|
||||||
"config": {
|
|
||||||
"configFolder": "libs/ui/.storybook"
|
|
||||||
},
|
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"ci": {
|
|
||||||
"quiet": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"ui-e2e": {
|
|
||||||
"root": "apps/ui-e2e",
|
|
||||||
"sourceRoot": "apps/ui-e2e/src",
|
|
||||||
"projectType": "application",
|
|
||||||
"architect": {
|
|
||||||
"e2e": {
|
|
||||||
"builder": "@nrwl/cypress:cypress",
|
|
||||||
"options": {
|
|
||||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
|
||||||
"devServerTarget": "ui:storybook",
|
|
||||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"ci": {
|
|
||||||
"devServerTarget": "ui:storybook:ci"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": [],
|
|
||||||
"implicitDependencies": ["ui"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
/* eslint-disable */
|
||||||
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
preset: '../../jest.preset.js',
|
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
@ -12,5 +13,6 @@ module.exports = {
|
|||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node'
|
testEnvironment: 'node',
|
||||||
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
57
apps/api/project.json
Normal file
57
apps/api/project.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/api/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "api",
|
||||||
|
"generators": {},
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@nrwl/webpack:webpack",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/api",
|
||||||
|
"main": "apps/api/src/main.ts",
|
||||||
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
|
"assets": ["apps/api/src/assets"],
|
||||||
|
"target": "node",
|
||||||
|
"compiler": "tsc"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"generatePackageJson": true,
|
||||||
|
"optimization": true,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"inspect": false,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "apps/api/src/environments/environment.ts",
|
||||||
|
"with": "apps/api/src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": ["{options.outputPath}"]
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"executor": "@nrwl/node:node",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "api:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nrwl/linter:eslint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@nrwl/jest:jest",
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
|
"passWithNoTests": true
|
||||||
|
},
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
@ -42,14 +42,16 @@ export class AccessController {
|
|||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
if (access.GranteeUser) {
|
if (access.GranteeUser) {
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser?.alias,
|
alias: access.alias,
|
||||||
|
grantee: access.GranteeUser?.id,
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'RESTRICTED_VIEW'
|
type: 'RESTRICTED_VIEW'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: 'Public',
|
alias: access.alias,
|
||||||
|
grantee: 'Public',
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'PUBLIC'
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
@ -71,6 +73,10 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
return this.accessService.createAccess({
|
||||||
|
alias: data.alias || undefined,
|
||||||
|
GranteeUser: data.granteeUserId
|
||||||
|
? { connect: { id: data.granteeUserId } }
|
||||||
|
: undefined,
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -78,8 +84,12 @@ export class AccessController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
!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),
|
||||||
@ -88,10 +98,7 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.deleteAccess({
|
return this.accessService.deleteAccess({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +1,11 @@
|
|||||||
export class CreateAccessDto {}
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccessDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
alias?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
granteeUserId?: string;
|
||||||
|
}
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import {
|
|
||||||
nullifyValuesInObject,
|
|
||||||
nullifyValuesInObjects
|
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } 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 {
|
||||||
|
AccountWithValue,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -19,7 +18,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} 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';
|
||||||
@ -35,9 +35,8 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -82,6 +81,7 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
@ -91,44 +91,33 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
.get()
|
userId: impersonationUserId || this.request.user.id,
|
||||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
withExcludedAccounts: true
|
||||||
|
});
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
return this.accountService.account({
|
public async getAccountById(
|
||||||
id_userId: {
|
@Headers('impersonation-id') impersonationId,
|
||||||
id,
|
@Param('id') id: string
|
||||||
userId: this.request.user.id
|
): Promise<AccountWithValue> {
|
||||||
}
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountsWithAggregations =
|
||||||
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
|
userId: impersonationUserId || this.request.user.id,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@ -102,22 +104,51 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCashDetails(
|
public async getCashDetails({
|
||||||
aUserId: string,
|
currency,
|
||||||
aCurrency: string
|
filters = [],
|
||||||
): Promise<CashDetails> {
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
|
}: {
|
||||||
|
currency: string;
|
||||||
|
filters?: Filter[];
|
||||||
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
|
}): Promise<CashDetails> {
|
||||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const accounts = await this.accounts({
|
const where: Prisma.AccountWhereInput = {
|
||||||
where: { userId: aUserId }
|
userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.isExcluded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.id = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.accounts({ where });
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
aCurrency
|
currency
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -11,6 +17,14 @@ export class CreateAccountDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +20,10 @@ export class UpdateAccountDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} 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';
|
||||||
@ -16,8 +22,10 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -27,6 +35,7 @@ import { isDate } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
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 { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@ -56,6 +65,24 @@ export class AdminController {
|
|||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gather7Days(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gather7Days();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
@ -71,10 +98,20 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
this.dataGatheringService.gatherMax();
|
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@ -92,9 +129,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -115,9 +161,14 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
return;
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -180,7 +231,9 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(
|
||||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||||
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -193,7 +246,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketData();
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...assetSubClasses.map((assetSubClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetSubClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.adminService.getMarketData(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -271,6 +335,32 @@ export class AdminController {
|
|||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async patchAssetProfileData(
|
||||||
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<EnhancedSymbolProfile> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.patchAssetProfileData({
|
||||||
|
...assetProfileData,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { QueueModule } from './queue/queue.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
|
@ -1,35 +1,39 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
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,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
@ -38,25 +42,22 @@ export class AdminService {
|
|||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
|
||||||
await this.dataGatheringService.getDataGatheringProgress(),
|
|
||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== baseCurrency;
|
return currency !== this.baseCurrency;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
label1: baseCurrency,
|
label1: this.baseCurrency,
|
||||||
label2: currency,
|
label2: currency,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
baseCurrency,
|
this.baseCurrency,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
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(),
|
||||||
@ -64,14 +65,27 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||||
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||||
this.exchangeRateDataService
|
|
||||||
|
if (filtersByAssetSubClass) {
|
||||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
|
} else {
|
||||||
|
currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
@ -85,17 +99,25 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol
|
symbol,
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
|
assetClass: true,
|
||||||
|
assetSubClass: true,
|
||||||
|
comment: true,
|
||||||
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -103,10 +125,14 @@ export class AdminService {
|
|||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
|
const countriesCount = symbolProfile.countries
|
||||||
|
? Object.keys(symbolProfile.countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketData.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -114,10 +140,18 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = symbolProfile.sectors
|
||||||
|
? Object.keys(symbolProfile.sectors).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
sectorsCount,
|
||||||
|
activitiesCount: symbolProfile._count.Order,
|
||||||
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
|
comment: symbolProfile.comment,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -133,8 +167,14 @@ export class AdminService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
@ -143,44 +183,53 @@ export class AdminService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetProfile,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
return symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public async putSetting(key: string, value: string) {
|
public async putSetting(key: string, value: string) {
|
||||||
let response: Property;
|
let response: Property;
|
||||||
|
|
||||||
if (value === '') {
|
if (value) {
|
||||||
response = await this.propertyService.delete({ key });
|
|
||||||
} else {
|
|
||||||
response = await this.propertyService.put({ key, value });
|
response = await this.propertyService.put({ key, value });
|
||||||
|
} else {
|
||||||
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
if (lastDataGathering) {
|
|
||||||
return lastDataGathering;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (dataGatheringInProgress) {
|
|
||||||
return 'IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -192,10 +241,10 @@ export class AdminService {
|
|||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
},
|
},
|
||||||
alias: true,
|
|
||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
|
country: true,
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -212,7 +261,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
@ -224,12 +273,12 @@ export class AdminService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
|
country: Analytics.country,
|
||||||
lastActivity: Analytics.updatedAt,
|
lastActivity: Analytics.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
|
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Controller('admin/queue')
|
||||||
|
export class QueueController {
|
||||||
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.deleteJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<AdminJobs> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.getJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('job/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueueController } from './queue.controller';
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QueueController],
|
||||||
|
imports: [DataGatheringModule],
|
||||||
|
providers: [QueueService]
|
||||||
|
})
|
||||||
|
export class QueueModule {}
|
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueueService {
|
||||||
|
public constructor(
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async deleteJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteJobs({
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
status?: JobStatus[];
|
||||||
|
}) {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(error, 'QueueService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getJobs({
|
||||||
|
limit = 1000,
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
status?: JobStatus[];
|
||||||
|
}): Promise<AdminJobs> {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
const jobsWithState = await Promise.all(
|
||||||
|
jobs.slice(0, limit).map(async (job) => {
|
||||||
|
return {
|
||||||
|
attemptsMade: job.attemptsMade + 1,
|
||||||
|
data: job.data,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobsWithState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
symbolMapping?: {
|
||||||
|
[dataProvider: string]: string;
|
||||||
|
};
|
||||||
|
}
|
@ -1,26 +1,17 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {
|
) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initialize() {
|
private async initialize() {
|
||||||
this.redisCacheService.reset();
|
try {
|
||||||
|
await this.exchangeRateDataService.initialize();
|
||||||
const isDataGatheringInProgress =
|
} catch {}
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (isDataGatheringInProgress) {
|
|
||||||
// Prepare for automatical data gathering, if hung up in progress state
|
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,30 +1,35 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
|
||||||
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 { ConfigurationModule } from '../services/configuration.module';
|
||||||
|
import { CronService } from '../services/cron.service';
|
||||||
|
import { DataGatheringModule } from '../services/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '../services/prisma.module';
|
||||||
|
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
|
import { RedisCacheModule } from './redis-cache/redis-cache.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 { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
@ -36,15 +41,25 @@ import { UserModule } from './user/user.module';
|
|||||||
AccountModule,
|
AccountModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BenchmarkModule,
|
||||||
|
BullModule.forRoot({
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
|
password: process.env.REDIS_PASSWORD
|
||||||
|
}
|
||||||
|
}),
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
@ -72,4 +87,10 @@ import { UserModule } from './user/user.module';
|
|||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply(FrontendMiddleware)
|
||||||
|
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -14,6 +16,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@ -31,7 +34,9 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
public async accessTokenLogin(
|
||||||
|
@Param('accessToken') accessToken: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
accessToken
|
||||||
@ -54,14 +59,42 @@ export class AuthController {
|
|||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = (<any>request.user).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
response.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
response.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('internet-identity/:principalId')
|
||||||
|
public async internetIdentityLogin(
|
||||||
|
@Param('principalId') principalId: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
|
principalId
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
|||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.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';
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { 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 { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -10,10 +12,11 @@ export class AuthService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async validateAnonymousLogin(accessToken: string) {
|
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const hashedAccessToken = this.userService.createAccessToken(
|
const hashedAccessToken = this.userService.createAccessToken(
|
||||||
@ -26,7 +29,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const jwt: string = this.jwtService.sign({
|
const jwt = this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +43,42 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async validateInternetIdentityLogin(principalId: string) {
|
||||||
|
try {
|
||||||
|
const provider: Provider = 'INTERNET_IDENTITY';
|
||||||
|
|
||||||
|
let [user] = await this.userService.users({
|
||||||
|
where: { provider, thirdPartyId: principalId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user if not found
|
||||||
|
user = await this.userService.createUser({
|
||||||
|
data: {
|
||||||
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'validateInternetIdentityLogin',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async validateOAuthLogin({
|
public async validateOAuthLogin({
|
||||||
provider,
|
provider,
|
||||||
thirdPartyId
|
thirdPartyId
|
||||||
@ -50,20 +89,30 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
|
data: {
|
||||||
provider,
|
provider,
|
||||||
thirdPartyId
|
thirdPartyId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt: string = this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
return jwt;
|
throw new InternalServerErrorException(
|
||||||
} catch (err) {
|
'validateOAuthLogin',
|
||||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
error.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ export class WebAuthService {
|
|||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
userName: user.alias,
|
userName: '',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
attestationType: 'indirect',
|
attestationType: 'indirect',
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
|
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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 {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkResponse
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Controller('benchmark')
|
||||||
|
export class BenchmarkController {
|
||||||
|
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BenchmarkController],
|
||||||
|
exports: [BenchmarkService],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
|
providers: [BenchmarkService]
|
||||||
|
})
|
||||||
|
export class BenchmarkModule {}
|
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
describe('BenchmarkService', () => {
|
||||||
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
|
||||||
|
});
|
||||||
|
});
|
210
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
210
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
MAX_CHART_ITEMS,
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BenchmarkService {
|
||||||
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||||
|
if (baseValue && currentValue) {
|
||||||
|
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||||
|
BenchmarkResponse['benchmarks']
|
||||||
|
> {
|
||||||
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
try {
|
||||||
|
benchmarks = JSON.parse(
|
||||||
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (benchmarks) {
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
|
const quotes = await this.dataProviderService.getQuotes(
|
||||||
|
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTimeHighs = await Promise.all(promises);
|
||||||
|
let storeInCache = true;
|
||||||
|
|
||||||
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
|
const { marketPrice } =
|
||||||
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||||
|
|
||||||
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
|
if (allTimeHigh && marketPrice) {
|
||||||
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
|
allTimeHigh,
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
storeInCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketCondition: this.getMarketCondition(
|
||||||
|
performancePercentFromAllTimeHigh
|
||||||
|
),
|
||||||
|
name: benchmarkAssetProfiles[index].name,
|
||||||
|
performances: {
|
||||||
|
allTimeHigh: {
|
||||||
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storeInCache) {
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
|
JSON.stringify(benchmarks),
|
||||||
|
ms('4 hours') / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
|
const symbolProfileIds: string[] = (
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||||
|
symbolProfileId: string;
|
||||||
|
}[]) ?? []
|
||||||
|
).map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
|
|
||||||
|
return assetProfiles
|
||||||
|
.map(({ dataSource, id, name, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
|
this.symbolService.get({
|
||||||
|
dataGatheringItem: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: {
|
||||||
|
gte: startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const step = Math.round(
|
||||||
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||||
|
const response = {
|
||||||
|
marketData: [
|
||||||
|
...marketDataItems
|
||||||
|
.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) {
|
||||||
|
response.marketData.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
currentSymbolItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
|
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
|
}
|
30
apps/api/src/app/cache/cache.controller.ts
vendored
30
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,25 +1,39 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {
|
) {}
|
||||||
this.redisCacheService.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
this.redisCacheService.reset();
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.cacheService.flush();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
apps/api/src/app/cache/cache.module.ts
vendored
5
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,4 +1,3 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [CacheService],
|
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
]
|
||||||
providers: [CacheService]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
15
apps/api/src/app/cache/cache.service.ts
vendored
15
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,15 +0,0 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CacheService {
|
|
||||||
public constructor(
|
|
||||||
private readonly dataGaterhingService: DataGatheringService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async flush(): Promise<void> {
|
|
||||||
await this.dataGaterhingService.reset();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
|
|
||||||
|
@Controller('exchange-rate')
|
||||||
|
export class ExchangeRateController {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateService: ExchangeRateService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getExchangeRate(
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
return this.exchangeRateService.getExchangeRate({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ExchangeRateController],
|
||||||
|
exports: [ExchangeRateService],
|
||||||
|
imports: [ExchangeRateDataModule],
|
||||||
|
providers: [ExchangeRateService]
|
||||||
|
})
|
||||||
|
export class ExchangeRateModule {}
|
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExchangeRateService {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getExchangeRate({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const [currency1, currency2] = symbol.split('-');
|
||||||
|
|
||||||
|
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
1,
|
||||||
|
currency1,
|
||||||
|
currency2,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketPrice };
|
||||||
|
}
|
||||||
|
}
|
@ -14,10 +14,27 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
accountType: true,
|
||||||
|
balance: true,
|
||||||
|
currency: true,
|
||||||
|
id: true,
|
||||||
|
isExcluded: true,
|
||||||
|
name: true,
|
||||||
|
platformId: true
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
let activities = await this.prismaService.order.findMany({
|
let activities = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
accountId: true,
|
accountId: true,
|
||||||
|
comment: true,
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
id: true,
|
id: true,
|
||||||
@ -37,11 +54,14 @@ export class ExportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
|
accounts,
|
||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
type,
|
type,
|
||||||
@ -49,13 +69,15 @@ export class ExportService {
|
|||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
date,
|
comment,
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
201
apps/api/src/app/frontend.middleware.ts
Normal file
201
apps/api/src/app/frontend.middleware.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
|
public indexHtmlDe = '';
|
||||||
|
public indexHtmlEn = '';
|
||||||
|
public indexHtmlEs = '';
|
||||||
|
public indexHtmlFr = '';
|
||||||
|
public indexHtmlIt = '';
|
||||||
|
public indexHtmlNl = '';
|
||||||
|
public indexHtmlPt = '';
|
||||||
|
public isProduction: boolean;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
const NODE_ENV =
|
||||||
|
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||||
|
'development';
|
||||||
|
|
||||||
|
this.isProduction = NODE_ENV === 'production';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.indexHtmlDe = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlEn = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlEs = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('es'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlFr = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('fr'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlNl = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlPt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public use(request: Request, response: Response, next: NextFunction) {
|
||||||
|
const currentDate = format(new Date(), DATE_FORMAT);
|
||||||
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||||
|
|
||||||
|
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
title = `500 Stars - ${title}`;
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||||
|
title = `Hacktoberfest 2022 - ${title}`;
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||||
|
title = `Black Friday 2022 - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||||
|
title = `The importance of tracking your personal finances - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||||
|
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.path.startsWith('/api/') ||
|
||||||
|
this.isFileRequest(request.url) ||
|
||||||
|
!this.isProduction
|
||||||
|
) {
|
||||||
|
// Skip
|
||||||
|
next();
|
||||||
|
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
languageCode: 'de',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlEs, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
languageCode: 'es',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'fr',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlIt, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
languageCode: 'it',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlNl, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
languageCode: 'nl',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'pt',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||||
|
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolate(template: string, context: any) {
|
||||||
|
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||||
|
const properties = objectPath.split('.');
|
||||||
|
return properties.reduce(
|
||||||
|
(previous, current) => previous?.[current],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileRequest(filename: string) {
|
||||||
|
if (filename === '/assets/LICENSE') {
|
||||||
|
return true;
|
||||||
|
} else if (filename.includes('auth/ey')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename.split('.').pop() !== filename;
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,15 @@
|
|||||||
|
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, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => CreateAccountDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
accounts: CreateAccountDto[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
@ -1,16 +1,25 @@
|
|||||||
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
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,
|
||||||
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ImportDataDto } from './import-data.dto';
|
import { ImportDataDto } from './import-data.dto';
|
||||||
@ -26,19 +35,47 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
public async import(
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
@Body() importData: ImportDataDto,
|
||||||
|
@Query('dryRun') isDryRun?: boolean
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
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),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxActivitiesToImport = this.configurationService.get(
|
||||||
|
'MAX_ACTIVITIES_TO_IMPORT'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Premium'
|
||||||
|
) {
|
||||||
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
activities: importData.activities,
|
isDryRun,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
|
accountsDto: importData.accounts ?? [],
|
||||||
|
activitiesDto: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, ImportController);
|
Logger.error(error, ImportController);
|
||||||
|
|
||||||
@ -51,4 +88,23 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async gatherDividends(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const activities = await this.importService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -1,28 +1,176 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
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 { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
AccountWithPlatform,
|
||||||
|
OrderWithAccount
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
|
try {
|
||||||
|
const { firstBuyDate, historicalData, orders } =
|
||||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
|
|
||||||
|
const [[assetProfile], dividends] = await Promise.all([
|
||||||
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
await this.dataProviderService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
from: parseDate(firstBuyDate),
|
||||||
|
granularity: 'day',
|
||||||
|
to: new Date()
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accounts = orders.map((order) => {
|
||||||
|
return order.Account;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
|
const quantity =
|
||||||
|
historicalData.find((historicalDataItem) => {
|
||||||
|
return historicalDataItem.date === dateString;
|
||||||
|
})?.quantity ?? 0;
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account,
|
||||||
|
quantity,
|
||||||
|
value,
|
||||||
|
accountId: Account?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
date: parseDate(dateString),
|
||||||
|
fee: 0,
|
||||||
|
feeInBaseCurrency: 0,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isDraft: false,
|
||||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
|
symbolProfileId: assetProfile.id,
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: marketPrice,
|
||||||
|
updatedAt: undefined,
|
||||||
|
userId: Account?.userId,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activities,
|
accountsDto,
|
||||||
|
activitiesDto,
|
||||||
|
isDryRun = false,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
isDryRun?: boolean;
|
||||||
|
maxActivitiesToImport: number;
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<Activity[]> {
|
||||||
for (const activity of activities) {
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
|
|
||||||
|
if (!isDryRun && accountsDto?.length) {
|
||||||
|
const existingAccounts = await this.accountService.accounts({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: accountsDto.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const account of accountsDto) {
|
||||||
|
// Check if there is any existing account with the same ID
|
||||||
|
const accountWithSameId = existingAccounts.find(
|
||||||
|
(existingAccount) => existingAccount.id === account.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||||
|
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||||
|
let oldAccountId: string;
|
||||||
|
const platformId = account.platformId;
|
||||||
|
|
||||||
|
delete account.platformId;
|
||||||
|
|
||||||
|
if (accountWithSameId) {
|
||||||
|
oldAccountId = account.id;
|
||||||
|
delete account.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccountObject = {
|
||||||
|
...account,
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (platformId) {
|
||||||
|
Object.assign(newAccountObject, {
|
||||||
|
Platform: { connect: { id: platformId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await this.accountService.createAccount(
|
||||||
|
newAccountObject,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the new to old account ID mappings for updating activities
|
||||||
|
if (accountWithSameId && oldAccountId) {
|
||||||
|
accountIdMapping[oldAccountId] = newAccount.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM') {
|
||||||
activity.dataSource = 'MANUAL';
|
activity.dataSource = 'MANUAL';
|
||||||
@ -30,35 +178,104 @@ export class ImportService {
|
|||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a new account is created, then update the accountId in all activities
|
||||||
|
if (!isDryRun) {
|
||||||
|
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
|
||||||
|
activity.accountId = accountIdMapping[activity.accountId];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({ activities, userId });
|
const assetProfiles = await this.validateActivities({
|
||||||
|
activitiesDto,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
(account) => {
|
||||||
return account.id;
|
return { id: account.id, name: account.name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
accountsDto.forEach(({ id, name }) => {
|
||||||
|
accounts.push({ id, name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date: dateString,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of activities) {
|
} of activitiesDto) {
|
||||||
await this.orderService.createOrder({
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
|
return id === accountId;
|
||||||
|
});
|
||||||
|
|
||||||
|
let order:
|
||||||
|
| OrderWithAccount
|
||||||
|
| (Omit<OrderWithAccount, 'Account'> & {
|
||||||
|
Account?: { id: string; name: string };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
order = {
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
userId,
|
userId,
|
||||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
accountId: validatedAccount?.id,
|
||||||
date: parseISO(<string>(<unknown>date)),
|
accountUserId: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
id: uuidv4(),
|
||||||
|
isDraft: isAfter(date, endOfToday()),
|
||||||
|
SymbolProfile: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetClass: null,
|
||||||
|
assetSubClass: null,
|
||||||
|
comment: null,
|
||||||
|
countries: null,
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null,
|
||||||
|
...assetProfiles[symbol]
|
||||||
|
},
|
||||||
|
Account: validatedAccount,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
order = await this.orderService.createOrder({
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: validatedAccount?.id,
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
@ -77,25 +294,55 @@ export class ImportService {
|
|||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
activities.push({
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
fee,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
uniqueAccountIds.add(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueAccountIds.size === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activities,
|
activitiesDto,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Too many activities (${this.configurationService.get(
|
|
||||||
'MAX_ORDERS_TO_IMPORT'
|
|
||||||
)} at most)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetProfiles: {
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
include: { SymbolProfile: true },
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
@ -105,7 +352,7 @@ export class ImportService {
|
|||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
] of activities.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
const duplicateActivity = existingActivities.find((activity) => {
|
const duplicateActivity = existingActivities.find((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
@ -124,22 +371,28 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const assetProfile = (
|
||||||
|
await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
if (quotes[symbol] === undefined) {
|
if (assetProfile === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quotes[symbol].currency !== currency) {
|
if (assetProfile.currency !== currency) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetProfiles[symbol] = assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return assetProfiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
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 } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ export class InfoController {
|
|||||||
public constructor(private readonly infoService: InfoService) {}
|
public constructor(private readonly infoService: InfoService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getInfo(): Promise<InfoItem> {
|
public async getInfo(): Promise<InfoItem> {
|
||||||
return this.infoService.get();
|
return this.infoService.get();
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -6,6 +7,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/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.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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';
|
||||||
|
|
||||||
@ -15,6 +17,7 @@ import { InfoService } from './info.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
imports: [
|
imports: [
|
||||||
|
BenchmarkModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -26,7 +29,8 @@ import { InfoService } from './info.service';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
})
|
})
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
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,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
encodeDataSource,
|
||||||
|
extractNumberFromString
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
@ -20,6 +25,7 @@ import { permissions } from '@ghostfolio/common/permissions';
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,13 +33,14 @@ export class InfoService {
|
|||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly tagService: TagService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -52,13 +59,17 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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') === true
|
||||||
|
) {
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
globalPermissions.push(permissions.enableImport);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
@ -78,6 +89,10 @@ export class InfoService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.countriesOfSubscribers =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||||
|
)) as string[]) ?? [];
|
||||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,17 +104,26 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (isUserSignupEnabled) {
|
||||||
|
globalPermissions.push(permissions.createUserAccount);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions(),
|
||||||
|
tags: await this.tagService.get()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,10 +153,10 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'json',
|
||||||
200,
|
200,
|
||||||
@ -141,8 +165,33 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const contributors = await get();
|
const { pull_count } = await get();
|
||||||
return contributors?.length;
|
return pull_count;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countGitHubContributors(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
'https://github.com/ghostfolio/ghostfolio',
|
||||||
|
'GET',
|
||||||
|
'string',
|
||||||
|
200,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await get();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
return extractNumberFromString(
|
||||||
|
$(
|
||||||
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
|
).text()
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
@ -205,13 +254,6 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
return lastDataGathering ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -232,6 +274,8 @@ export class InfoService {
|
|||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
|
|
||||||
|
const dockerHubPulls = await this.countDockerHubPulls();
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
@ -239,6 +283,7 @@ export class InfoService {
|
|||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
|
dockerHubPulls,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
@ -258,14 +303,14 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prismaService.property.findUnique({
|
let subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
});
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
if (stripeConfig) {
|
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||||
return [JSON.parse(stripeConfig.value)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return subscriptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Controller('logo')
|
||||||
|
export class LogoController {
|
||||||
|
public constructor(private readonly logoService: LogoService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getLogoByDataSourceAndSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getLogoByUrl(
|
||||||
|
@Query('url') url: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByUrl(url);
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LogoController } from './logo.controller';
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LogoController],
|
||||||
|
imports: [ConfigurationModule, SymbolProfileModule],
|
||||||
|
providers: [LogoService]
|
||||||
|
})
|
||||||
|
export class LogoModule {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import * as bent from 'bent';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
public constructor(
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBuffer(assetProfile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoByUrl(aUrl: string) {
|
||||||
|
return this.getBuffer(aUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuffer(aUrl: string) {
|
||||||
|
const get = bent(
|
||||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
'GET',
|
||||||
|
'buffer',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'User-Agent': 'request'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +1,47 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
accountId: string;
|
@IsString()
|
||||||
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dataSource: DataSource;
|
@IsEnum(DataSource, { each: true })
|
||||||
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
date: string;
|
date: string;
|
||||||
@ -31,6 +55,10 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsEnum(Type, { each: true })
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -6,5 +6,6 @@ export interface Activities {
|
|||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
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';
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -33,17 +34,21 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
@Controller('order')
|
@Controller('order')
|
||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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 });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||||
|
!order ||
|
||||||
|
order.userId !== this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -52,46 +57,41 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.deleteOrder({
|
return this.orderService.deleteOrder({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
userId: impersonationUserId || this.request.user.id
|
userId: impersonationUserId || this.request.user.id,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
activities = nullifyValuesInObjects(activities, [
|
|
||||||
'fee',
|
|
||||||
'feeInBaseCurrency',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,23 +135,15 @@ export class OrderController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!originalOrder) {
|
if (
|
||||||
|
!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
|
||||||
@ -178,15 +170,17 @@ export class OrderController {
|
|||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
assetClass: data.assetClass,
|
||||||
|
assetSubClass: data.assetSubClass,
|
||||||
|
name: data.symbol
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
|||||||
controllers: [OrderController],
|
controllers: [OrderController],
|
||||||
exports: [OrderService],
|
exports: [OrderService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
|
@ -1,14 +1,27 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { Filter } 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 { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Order,
|
||||||
|
Prisma,
|
||||||
|
Tag,
|
||||||
|
Type as TypeOfOrder
|
||||||
|
} 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 { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { Activity } from './interfaces/activities.interface';
|
||||||
@ -17,9 +30,8 @@ import { Activity } from './interfaces/activities.interface';
|
|||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -55,35 +67,41 @@ export class OrderService {
|
|||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const defaultAccount = (
|
let Account;
|
||||||
await this.accountService.getAccounts(data.userId)
|
|
||||||
).find((account) => {
|
|
||||||
return account.isDefault === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let Account = {
|
if (data.accountId) {
|
||||||
|
Account = {
|
||||||
connect: {
|
connect: {
|
||||||
id_userId: {
|
id_userId: {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
id: data.accountId ?? defaultAccount?.id
|
id: data.accountId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
|
const assetClass = data.assetClass;
|
||||||
|
const assetSubClass = data.assetSubClass;
|
||||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
const 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;
|
||||||
|
|
||||||
Account = undefined;
|
|
||||||
data.id = id;
|
data.id = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
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;
|
||||||
@ -97,12 +115,14 @@ export class OrderService {
|
|||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.gatherProfileData([
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
{
|
{
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
}
|
},
|
||||||
]);
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
@ -117,12 +137,18 @@ export class OrderService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
delete data.comment;
|
||||||
|
}
|
||||||
|
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
@ -131,7 +157,12 @@ export class OrderService {
|
|||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -151,22 +182,81 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.accountId = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersByAssetClass?.length > 0) {
|
||||||
|
where.SymbolProfile = {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ SymbolProfileOverrides: { is: null } },
|
||||||
|
{ SymbolProfileOverrides: { assetClass: null } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtersByTag?.length > 0) {
|
||||||
|
where.tags = {
|
||||||
|
some: {
|
||||||
|
OR: filtersByTag.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.OR = types.map((type) => {
|
||||||
return {
|
return {
|
||||||
@ -188,11 +278,16 @@ 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
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' }
|
orderBy: { date: 'asc' }
|
||||||
})
|
})
|
||||||
).map((order) => {
|
)
|
||||||
|
.filter((order) => {
|
||||||
|
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -217,9 +312,12 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
}: {
|
}: {
|
||||||
data: Prisma.OrderUpdateInput & {
|
data: Prisma.OrderUpdateInput & {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
@ -227,13 +325,19 @@ export class OrderService {
|
|||||||
delete data.Account;
|
delete data.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
data.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
|
delete data.SymbolProfile.connect;
|
||||||
|
|
||||||
data.SymbolProfile = { update: { name } };
|
|
||||||
} else {
|
} else {
|
||||||
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
isDraft = isAfter(data.date as Date, endOfToday());
|
isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
@ -248,16 +352,28 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
|
|
||||||
|
// Remove existing tags
|
||||||
|
await this.prismaService.order.update({
|
||||||
|
data: { tags: { set: [] } },
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,40 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import {
|
||||||
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
@ -27,6 +57,10 @@ export class UpdateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
function mockGetValue(symbol: string, date: Date) {
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
@ -20,14 +21,35 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
case 'BTCUSD':
|
||||||
|
if (isSameDay(parseDate('2015-01-01'), date)) {
|
||||||
|
return { marketPrice: 314.25 };
|
||||||
|
} else if (isSameDay(parseDate('2017-12-31'), date)) {
|
||||||
|
return { marketPrice: 14156.4 };
|
||||||
|
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||||
|
return { marketPrice: 13657.2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
case 'NOVN.SW':
|
||||||
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
|
return { marketPrice: 87.8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CurrentRateServiceMock = {
|
export const CurrentRateServiceMock = {
|
||||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
getValues: ({
|
||||||
const result = [];
|
dataGatheringItems,
|
||||||
|
dateQuery
|
||||||
|
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||||
|
const result: GetValueObject[] = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -37,8 +59,10 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date,
|
date,
|
||||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
.marketPrice,
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -48,8 +72,10 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date,
|
date,
|
||||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
.marketPrice,
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
|||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -73,7 +74,13 @@ describe('CurrentRateService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
await exchangeRateDataService.initialize();
|
||||||
@ -96,15 +103,15 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject([
|
).toMatchObject<GetValueObject[]>([
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1847.839966,
|
marketPriceInBaseCurrency: 1847.839966,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
@ -28,13 +28,7 @@ export class CurrentRateService {
|
|||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
{
|
|
||||||
date: Date;
|
|
||||||
marketPrice: number;
|
|
||||||
symbol: string;
|
|
||||||
}[]
|
|
||||||
>[] = [];
|
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
@ -42,13 +36,14 @@ export class CurrentRateService {
|
|||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date: today,
|
date: today,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
this.exchangeRateDataService.toCurrency(
|
||||||
0,
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
|
?.marketPrice ?? 0,
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
@ -74,7 +69,8 @@ export class CurrentRateService {
|
|||||||
return data.map((marketDataItem) => {
|
return data.map((marketDataItem) => {
|
||||||
return {
|
return {
|
||||||
date: marketDataItem.date,
|
date: marketDataItem.date,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
marketDataItem.marketPrice,
|
marketDataItem.marketPrice,
|
||||||
currencies[marketDataItem.symbol],
|
currencies[marketDataItem.symbol],
|
||||||
userCurrency
|
userCurrency
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export interface GetValueObject {
|
export interface GetValueObject {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPriceInBaseCurrency: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
HistoricalDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
|
dividendInBaseCurrency: number;
|
||||||
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
@ -16,6 +22,7 @@ export interface PortfolioPositionDetail {
|
|||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
SymbolProfile: EnhancedSymbolProfile;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
|
tags: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
@ -25,10 +32,3 @@ export interface HistoricalDataContainer {
|
|||||||
isAllTimeLow: boolean;
|
isAllTimeLow: boolean;
|
||||||
items: HistoricalDataItem[];
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
|
||||||
averagePrice?: number;
|
|
||||||
date: string;
|
|
||||||
grossPerformancePercent?: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
@ -52,16 +52,21 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -77,6 +82,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
@ -91,6 +97,15 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('0')
|
totalInvestment: 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: new Big('12.6') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy', async () => {
|
it.only('with BALN.SW buy', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
@ -41,16 +41,21 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
parseDate('2021-11-30')
|
parseDate('2021-11-30')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -66,6 +71,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
averagePrice: new Big('136.6'),
|
averagePrice: new Big('136.6'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
@ -80,6 +86,14 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('273.2')
|
totalInvestment: new Big('273.2')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -0,0 +1,112 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BTCUSD buy and sell partially', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2015-01-01',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(0),
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BTCUSD',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(320.43)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2017-12-31',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(0),
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
quantity: new Big(1),
|
||||||
|
symbol: 'BTCUSD',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(14156.4)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2015-01-01')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('13657.2'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('27172.74'),
|
||||||
|
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('27172.74'),
|
||||||
|
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('320.43'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2015-01-01',
|
||||||
|
grossPerformance: new Big('27172.74'),
|
||||||
|
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||||
|
investment: new Big('320.43'),
|
||||||
|
netPerformance: new Big('27172.74'),
|
||||||
|
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||||
|
marketPrice: 13657.2,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'BTCUSD',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('320.43')
|
||||||
|
});
|
||||||
|
|
||||||
|
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: new Big('640.86') },
|
||||||
|
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,73 +0,0 @@
|
|||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
|
||||||
currentRateService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercent: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercent: new Big(0.05)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.05);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercent: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,997 +0,0 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
ResponseError,
|
|
||||||
TimelinePosition,
|
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
addMilliseconds,
|
|
||||||
addMonths,
|
|
||||||
addYears,
|
|
||||||
endOfDay,
|
|
||||||
format,
|
|
||||||
isAfter,
|
|
||||||
isBefore,
|
|
||||||
max,
|
|
||||||
min
|
|
||||||
} from 'date-fns';
|
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
|
||||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
|
||||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
|
||||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
|
||||||
import {
|
|
||||||
Accuracy,
|
|
||||||
TimelineSpecification
|
|
||||||
} from './interfaces/timeline-specification.interface';
|
|
||||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
|
||||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
|
||||||
|
|
||||||
export class PortfolioCalculatorNew {
|
|
||||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
|
||||||
true;
|
|
||||||
|
|
||||||
private static readonly ENABLE_LOGGING = false;
|
|
||||||
|
|
||||||
private currency: string;
|
|
||||||
private currentRateService: CurrentRateService;
|
|
||||||
private orders: PortfolioOrder[];
|
|
||||||
private transactionPoints: TransactionPoint[];
|
|
||||||
|
|
||||||
public constructor({
|
|
||||||
currency,
|
|
||||||
currentRateService,
|
|
||||||
orders
|
|
||||||
}: {
|
|
||||||
currency: string;
|
|
||||||
currentRateService: CurrentRateService;
|
|
||||||
orders: PortfolioOrder[];
|
|
||||||
}) {
|
|
||||||
this.currency = currency;
|
|
||||||
this.currentRateService = currentRateService;
|
|
||||||
this.orders = orders;
|
|
||||||
|
|
||||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
public computeTransactionPoints() {
|
|
||||||
this.transactionPoints = [];
|
|
||||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
|
||||||
|
|
||||||
let lastDate: string = null;
|
|
||||||
let lastTransactionPoint: TransactionPoint = null;
|
|
||||||
for (const order of this.orders) {
|
|
||||||
const currentDate = order.date;
|
|
||||||
|
|
||||||
let currentTransactionPointItem: TransactionPointSymbol;
|
|
||||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
|
||||||
|
|
||||||
const factor = this.getFactor(order.type);
|
|
||||||
const unitPrice = new Big(order.unitPrice);
|
|
||||||
if (oldAccumulatedSymbol) {
|
|
||||||
const newQuantity = order.quantity
|
|
||||||
.mul(factor)
|
|
||||||
.plus(oldAccumulatedSymbol.quantity);
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
|
||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
|
||||||
investment: newQuantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: unitPrice
|
|
||||||
.mul(order.quantity)
|
|
||||||
.mul(factor)
|
|
||||||
.plus(oldAccumulatedSymbol.investment),
|
|
||||||
quantity: newQuantity,
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee,
|
|
||||||
firstBuyDate: order.date,
|
|
||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
|
||||||
quantity: order.quantity.mul(factor),
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols[order.symbol] = currentTransactionPointItem;
|
|
||||||
|
|
||||||
const items = lastTransactionPoint?.items ?? [];
|
|
||||||
const newItems = items.filter(
|
|
||||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
|
||||||
);
|
|
||||||
newItems.push(currentTransactionPointItem);
|
|
||||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
||||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
|
||||||
lastTransactionPoint = {
|
|
||||||
date: currentDate,
|
|
||||||
items: newItems
|
|
||||||
};
|
|
||||||
this.transactionPoints.push(lastTransactionPoint);
|
|
||||||
} else {
|
|
||||||
lastTransactionPoint.items = newItems;
|
|
||||||
}
|
|
||||||
lastDate = currentDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket,
|
|
||||||
netPerformancePercent
|
|
||||||
}: {
|
|
||||||
daysInMarket: number;
|
|
||||||
netPerformancePercent: Big;
|
|
||||||
}): Big {
|
|
||||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
|
||||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
|
||||||
return new Big(
|
|
||||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
|
||||||
).minus(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTransactionPoints(): TransactionPoint[] {
|
|
||||||
return this.transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
|
||||||
this.transactionPoints = transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
|
||||||
if (!this.transactionPoints?.length) {
|
|
||||||
return {
|
|
||||||
currentValue: new Big(0),
|
|
||||||
hasErrors: false,
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
positions: [],
|
|
||||||
totalInvestment: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTransactionPoint =
|
|
||||||
this.transactionPoints[this.transactionPoints.length - 1];
|
|
||||||
|
|
||||||
// use Date.now() to use the mock for today
|
|
||||||
const today = new Date(Date.now());
|
|
||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
|
||||||
let firstIndex = this.transactionPoints.length;
|
|
||||||
const dates = [];
|
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
const currencies: { [symbol: string]: string } = {};
|
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
currencies[item.symbol] = item.currency;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
|
||||||
if (
|
|
||||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
|
||||||
firstTransactionPoint === null
|
|
||||||
) {
|
|
||||||
firstTransactionPoint = this.transactionPoints[i];
|
|
||||||
firstIndex = i;
|
|
||||||
}
|
|
||||||
if (firstTransactionPoint !== null) {
|
|
||||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dates.push(resetHours(today));
|
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
in: dates
|
|
||||||
},
|
|
||||||
userCurrency: this.currency
|
|
||||||
});
|
|
||||||
|
|
||||||
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 todayString = format(today, DATE_FORMAT);
|
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
|
||||||
firstIndex--;
|
|
||||||
}
|
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
|
||||||
|
|
||||||
const positions: TimelinePosition[] = [];
|
|
||||||
let hasAnySymbolMetricsErrors = false;
|
|
||||||
|
|
||||||
const errors: ResponseError['errors'] = [];
|
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
|
||||||
|
|
||||||
const {
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
initialValue,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage
|
|
||||||
} = this.getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
|
|
||||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
|
||||||
initialValues[item.symbol] = initialValue;
|
|
||||||
|
|
||||||
positions.push({
|
|
||||||
averagePrice: item.quantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: item.investment.div(item.quantity),
|
|
||||||
currency: item.currency,
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
firstBuyDate: item.firstBuyDate,
|
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
|
||||||
grossPerformancePercentage: !hasErrors
|
|
||||||
? grossPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
investment: item.investment,
|
|
||||||
marketPrice: marketValue?.toNumber() ?? null,
|
|
||||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
|
||||||
netPerformancePercentage: !hasErrors
|
|
||||||
? netPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
quantity: item.quantity,
|
|
||||||
symbol: item.symbol,
|
|
||||||
transactionCount: item.transactionCount
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasErrors) {
|
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...overall,
|
|
||||||
errors,
|
|
||||||
positions,
|
|
||||||
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 async calculateTimeline(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
endDate: string
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
if (timelineSpecification.length === 0) {
|
|
||||||
return {
|
|
||||||
maxNetPerformance: new Big(0),
|
|
||||||
minNetPerformance: new Big(0),
|
|
||||||
timelinePeriods: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = timelineSpecification[0].start;
|
|
||||||
const start = parseDate(startDate);
|
|
||||||
const end = parseDate(endDate);
|
|
||||||
|
|
||||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
|
||||||
let i = 0;
|
|
||||||
let j = -1;
|
|
||||||
for (
|
|
||||||
let currentDate = start;
|
|
||||||
!isAfter(currentDate, end);
|
|
||||||
currentDate = this.addToDate(
|
|
||||||
currentDate,
|
|
||||||
timelineSpecification[i].accuracy
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
j + 1 < this.transactionPoints.length &&
|
|
||||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
|
||||||
) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let periodEndDate = currentDate;
|
|
||||||
if (timelineSpecification[i].accuracy === 'day') {
|
|
||||||
let nextEndDate = end;
|
|
||||||
if (j + 1 < this.transactionPoints.length) {
|
|
||||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
|
||||||
}
|
|
||||||
periodEndDate = min([
|
|
||||||
addMonths(currentDate, 3),
|
|
||||||
max([currentDate, nextEndDate])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
const timePeriodForDates = this.getTimePeriodForDate(
|
|
||||||
j,
|
|
||||||
currentDate,
|
|
||||||
endOfDay(periodEndDate)
|
|
||||||
);
|
|
||||||
currentDate = periodEndDate;
|
|
||||||
if (timePeriodForDates != null) {
|
|
||||||
timelinePeriodPromises.push(timePeriodForDates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
|
||||||
timelinePeriodPromises
|
|
||||||
);
|
|
||||||
const minNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((minPerformance, current) => {
|
|
||||||
if (minPerformance.lt(current)) {
|
|
||||||
return minPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((maxPerformance, current) => {
|
|
||||||
if (maxPerformance.gt(current)) {
|
|
||||||
return maxPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const timelinePeriods = timelineInfoInterfaces.map(
|
|
||||||
(timelineInfo) => timelineInfo.timelinePeriods
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: flatten(timelinePeriods)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateOverallPerformance(
|
|
||||||
positions: TimelinePosition[],
|
|
||||||
initialValues: { [symbol: string]: Big }
|
|
||||||
) {
|
|
||||||
let currentValue = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformancePercentage = new Big(0);
|
|
||||||
let hasErrors = false;
|
|
||||||
let netPerformance = new Big(0);
|
|
||||||
let netPerformancePercentage = new Big(0);
|
|
||||||
let sumOfWeights = new Big(0);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
|
||||||
if (currentPosition.marketPrice) {
|
|
||||||
currentValue = currentValue.plus(
|
|
||||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
|
||||||
|
|
||||||
if (currentPosition.grossPerformance) {
|
|
||||||
grossPerformance = grossPerformance.plus(
|
|
||||||
currentPosition.grossPerformance
|
|
||||||
);
|
|
||||||
|
|
||||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPosition.grossPerformancePercentage) {
|
|
||||||
// Use the average from the initial value and the current investment as
|
|
||||||
// a weight
|
|
||||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
|
||||||
.plus(currentPosition.investment)
|
|
||||||
.div(2);
|
|
||||||
|
|
||||||
sumOfWeights = sumOfWeights.plus(weight);
|
|
||||||
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
|
||||||
currentPosition.grossPerformancePercentage.mul(weight)
|
|
||||||
);
|
|
||||||
|
|
||||||
netPerformancePercentage = netPerformancePercentage.plus(
|
|
||||||
currentPosition.netPerformancePercentage.mul(weight)
|
|
||||||
);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
Logger.warn(
|
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
|
||||||
'PortfolioCalculatorNew'
|
|
||||||
);
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sumOfWeights.gt(0)) {
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
|
||||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
|
||||||
} else {
|
|
||||||
grossPerformancePercentage = new Big(0);
|
|
||||||
netPerformancePercentage = new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentValue,
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage,
|
|
||||||
totalInvestment
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTimePeriodForDate(
|
|
||||||
j: number,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
let investment: Big = new Big(0);
|
|
||||||
let fees: Big = new Big(0);
|
|
||||||
|
|
||||||
const marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
} = {};
|
|
||||||
if (j >= 0) {
|
|
||||||
const currencies: { [name: string]: string } = {};
|
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
currencies[item.symbol] = item.currency;
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
investment = investment.plus(item.investment);
|
|
||||||
fees = fees.plus(item.fee);
|
|
||||||
}
|
|
||||||
|
|
||||||
let marketSymbols: GetValueObject[] = [];
|
|
||||||
if (dataGatheringItems.length > 0) {
|
|
||||||
try {
|
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
gte: startDate,
|
|
||||||
lt: endOfDay(endDate)
|
|
||||||
},
|
|
||||||
userCurrency: this.currency
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(
|
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
|
||||||
error,
|
|
||||||
'PortfolioCalculatorNew'
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 results: TimelinePeriod[] = [];
|
|
||||||
let maxNetPerformance: Big = null;
|
|
||||||
let minNetPerformance: Big = null;
|
|
||||||
for (
|
|
||||||
let currentDate = startDate;
|
|
||||||
isBefore(currentDate, endDate);
|
|
||||||
currentDate = addDays(currentDate, 1)
|
|
||||||
) {
|
|
||||||
let value = new Big(0);
|
|
||||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
|
||||||
let invalid = false;
|
|
||||||
if (j >= 0) {
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
if (
|
|
||||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
|
||||||
) {
|
|
||||||
invalid = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
value = value.plus(
|
|
||||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!invalid) {
|
|
||||||
const grossPerformance = value.minus(investment);
|
|
||||||
const netPerformance = grossPerformance.minus(fees);
|
|
||||||
if (
|
|
||||||
minNetPerformance === null ||
|
|
||||||
minNetPerformance.gt(netPerformance)
|
|
||||||
) {
|
|
||||||
minNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
maxNetPerformance === null ||
|
|
||||||
maxNetPerformance.lt(netPerformance)
|
|
||||||
) {
|
|
||||||
maxNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
grossPerformance,
|
|
||||||
investment,
|
|
||||||
netPerformance,
|
|
||||||
value,
|
|
||||||
date: currentDateAsString
|
|
||||||
};
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: results
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFactor(type: TypeOfOrder) {
|
|
||||||
let factor: number;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'BUY':
|
|
||||||
factor = 1;
|
|
||||||
break;
|
|
||||||
case 'SELL':
|
|
||||||
factor = -1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
factor = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
|
||||||
switch (accuracy) {
|
|
||||||
case 'day':
|
|
||||||
return addDays(date, 1);
|
|
||||||
case 'month':
|
|
||||||
return addMonths(date, 1);
|
|
||||||
case 'year':
|
|
||||||
return addYears(date, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
};
|
|
||||||
start: Date;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
|
||||||
return order.symbol === symbol;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
|
||||||
return {
|
|
||||||
hasErrors: false,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
|
||||||
const endDate = new Date(Date.now());
|
|
||||||
|
|
||||||
const unitPriceAtStartDate =
|
|
||||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
const unitPriceAtEndDate =
|
|
||||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!unitPriceAtEndDate ||
|
|
||||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
hasErrors: true,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let averagePriceAtEndDate = new Big(0);
|
|
||||||
let averagePriceAtStartDate = new Big(0);
|
|
||||||
let feesAtStartDate = new Big(0);
|
|
||||||
let fees = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformanceAtStartDate = new Big(0);
|
|
||||||
let grossPerformanceFromSells = new Big(0);
|
|
||||||
let initialValue: Big;
|
|
||||||
let investmentAtStartDate: Big;
|
|
||||||
let lastAveragePrice = new Big(0);
|
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
|
||||||
let totalUnits = new Big(0);
|
|
||||||
let valueAtStartDate: Big;
|
|
||||||
|
|
||||||
// Add a synthetic order at the start and the end date
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(start, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'start',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtStartDate
|
|
||||||
});
|
|
||||||
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(endDate, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'end',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtEndDate
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort orders so that the start and end placeholder order are at the right
|
|
||||||
// position
|
|
||||||
orders = sortBy(orders, (order) => {
|
|
||||||
let sortIndex = new Date(order.date);
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.itemType === 'end') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortIndex.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexOfStartOrder = orders.findIndex((order) => {
|
|
||||||
return order.itemType === 'start';
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexOfEndOrder = orders.findIndex((order) => {
|
|
||||||
return order.itemType === 'end';
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < orders.length; i += 1) {
|
|
||||||
const order = orders[i];
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
// Take the unit price of the order as the market price if there are no
|
|
||||||
// orders of this symbol before the start date
|
|
||||||
order.unitPrice =
|
|
||||||
indexOfStartOrder === 0
|
|
||||||
? orders[i + 1]?.unitPrice
|
|
||||||
: unitPriceAtStartDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the average start price as soon as any units are held
|
|
||||||
if (
|
|
||||||
averagePriceAtStartDate.eq(0) &&
|
|
||||||
i >= indexOfStartOrder &&
|
|
||||||
totalUnits.gt(0)
|
|
||||||
) {
|
|
||||||
averagePriceAtStartDate = totalInvestment.div(totalUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
|
||||||
order.unitPrice
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
|
||||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
|
||||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
|
||||||
.mul(order.unitPrice)
|
|
||||||
.mul(this.getFactor(order.type));
|
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
|
||||||
|
|
||||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
|
||||||
maxTotalInvestment = totalInvestment;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i === indexOfEndOrder && totalUnits.gt(0)) {
|
|
||||||
averagePriceAtEndDate = totalInvestment.div(totalUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i >= indexOfStartOrder && !initialValue) {
|
|
||||||
if (
|
|
||||||
i === indexOfStartOrder &&
|
|
||||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
|
||||||
) {
|
|
||||||
initialValue = valueOfInvestmentBeforeTransaction;
|
|
||||||
} else if (transactionInvestment.gt(0)) {
|
|
||||||
initialValue = transactionInvestment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fees = fees.plus(order.fee);
|
|
||||||
|
|
||||||
totalUnits = totalUnits.plus(
|
|
||||||
order.quantity.mul(this.getFactor(order.type))
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
|
||||||
|
|
||||||
const grossPerformanceFromSell =
|
|
||||||
order.type === TypeOfOrder.SELL
|
|
||||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
|
||||||
: new Big(0);
|
|
||||||
|
|
||||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
|
||||||
grossPerformanceFromSell
|
|
||||||
);
|
|
||||||
|
|
||||||
totalInvestmentWithGrossPerformanceFromSell =
|
|
||||||
totalInvestmentWithGrossPerformanceFromSell
|
|
||||||
.plus(transactionInvestment)
|
|
||||||
.plus(grossPerformanceFromSell);
|
|
||||||
|
|
||||||
lastAveragePrice = totalUnits.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
|
||||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
|
||||||
.plus(grossPerformanceFromSells);
|
|
||||||
|
|
||||||
if (
|
|
||||||
i > indexOfStartOrder &&
|
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
|
||||||
.plus(lastTransactionInvestment)
|
|
||||||
.eq(0)
|
|
||||||
) {
|
|
||||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.minus(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(grossHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
|
|
||||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.minus(fees.minus(feesAtStartDate))
|
|
||||||
.minus(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(netHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
feesAtStartDate = fees;
|
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
|
||||||
grossPerformanceAtStartDate
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalNetPerformance = grossPerformance
|
|
||||||
.minus(grossPerformanceAtStartDate)
|
|
||||||
.minus(fees.minus(feesAtStartDate));
|
|
||||||
|
|
||||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
|
||||||
maxTotalInvestment.minus(investmentAtStartDate)
|
|
||||||
);
|
|
||||||
|
|
||||||
const grossPerformancePercentage =
|
|
||||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
|
||||||
averagePriceAtEndDate.eq(0) ||
|
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
|
||||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
|
||||||
: new Big(0)
|
|
||||||
: // This formula has the issue that buying more units with a price
|
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
|
||||||
.minus(1);
|
|
||||||
|
|
||||||
const feesPerUnit = totalUnits.gt(0)
|
|
||||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
|
||||||
: new Big(0);
|
|
||||||
|
|
||||||
const netPerformancePercentage =
|
|
||||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
|
||||||
averagePriceAtEndDate.eq(0) ||
|
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
|
||||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
|
||||||
: new Big(0)
|
|
||||||
: // This formula has the issue that buying more units with a price
|
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.minus(feesPerUnit)
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
|
||||||
.minus(1);
|
|
||||||
|
|
||||||
if (PortfolioCalculatorNew.ENABLE_LOGGING) {
|
|
||||||
console.log(
|
|
||||||
`
|
|
||||||
${symbol}
|
|
||||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
|
||||||
2
|
|
||||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
|
||||||
Average price: ${averagePriceAtStartDate.toFixed(
|
|
||||||
2
|
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
|
||||||
2
|
|
||||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
|
||||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
|
||||||
Net performance: ${totalNetPerformance.toFixed(
|
|
||||||
2
|
|
||||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialValue,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
netPerformancePercentage,
|
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
|
||||||
netPerformance: totalNetPerformance,
|
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private isNextItemActive(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
currentDate: Date,
|
|
||||||
i: number
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
i + 1 < timelineSpecification.length &&
|
|
||||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,22 +23,27 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it('with no orders', async () => {
|
it('with no orders', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: []
|
orders: []
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -51,6 +56,10 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
positions: [],
|
positions: [],
|
||||||
totalInvestment: new Big(0)
|
totalInvestment: new Big(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -0,0 +1,112 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-03-07',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.3),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(75.8)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-04-08',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(2.95),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(1),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(85.73)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('87.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('75.80'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('4.25'),
|
||||||
|
firstBuyDate: '2022-03-07',
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
investment: new Big('75.80'),
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||||
|
marketPrice: 87.8,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('75.80')
|
||||||
|
});
|
||||||
|
|
||||||
|
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: new Big('151.6') },
|
||||||
|
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,132 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with NOVN.SW buy and sell', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-03-07',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(0),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(75.8)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-04-08',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(0),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(85.73)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(chartData[0]).toEqual({
|
||||||
|
date: '2022-03-07',
|
||||||
|
netPerformanceInPercentage: 0,
|
||||||
|
netPerformance: 0,
|
||||||
|
totalInvestment: 151.6,
|
||||||
|
value: 151.6
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chartData[chartData.length - 1]).toEqual({
|
||||||
|
date: '2022-04-11',
|
||||||
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
|
netPerformance: 19.86,
|
||||||
|
totalInvestment: 0,
|
||||||
|
value: 19.86
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('19.86'),
|
||||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('19.86'),
|
||||||
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('0'),
|
||||||
|
firstBuyDate: '2022-03-07',
|
||||||
|
grossPerformance: new Big('19.86'),
|
||||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
netPerformance: new Big('19.86'),
|
||||||
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
marketPrice: 87.8,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,26 +0,0 @@
|
|||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PortfolioServiceStrategy {
|
|
||||||
public constructor(
|
|
||||||
private readonly portfolioService: PortfolioService,
|
|
||||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public get(newCalculationEngine?: boolean) {
|
|
||||||
if (
|
|
||||||
newCalculationEngine ||
|
|
||||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
|
||||||
) {
|
|
||||||
return this.portfolioServiceNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.portfolioService;
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,22 +4,25 @@ import {
|
|||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
PortfolioChart,
|
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioDividends,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport
|
||||||
PortfolioSummary
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type {
|
||||||
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -29,104 +32,79 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors,
|
||||||
|
Version
|
||||||
} 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 { ViewMode } from '@prisma/client';
|
import Big from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('chart')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getChart(
|
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
|
||||||
@Query('range') range
|
|
||||||
): Promise<PortfolioChart> {
|
|
||||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
|
||||||
.get()
|
|
||||||
.getChart(impersonationId, range);
|
|
||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
|
||||||
|
|
||||||
let hasError = false;
|
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
|
||||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
) {
|
||||||
let maxValue = 0;
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
|
||||||
chartData.forEach((portfolioItem) => {
|
|
||||||
if (portfolioItem.value > maxValue) {
|
|
||||||
maxValue = portfolioItem.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
chartData = chartData.map((historicalDataItem) => {
|
|
||||||
return {
|
|
||||||
...historicalDataItem,
|
|
||||||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasError,
|
|
||||||
chart: chartData,
|
|
||||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
|
||||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
if (
|
let hasDetails = true;
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
await this.portfolioServiceStrategy
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
.get(true)
|
}
|
||||||
.getDetails(impersonationId, this.request.user.id, range);
|
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
filteredValueInBaseCurrency,
|
||||||
|
filteredValueInPercentage,
|
||||||
|
hasErrors,
|
||||||
|
holdings,
|
||||||
|
summary,
|
||||||
|
totalValueInBaseCurrency
|
||||||
|
} = await this.portfolioService.getDetails({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let portfolioSummary = summary;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -142,7 +120,7 @@ export class PortfolioController {
|
|||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user.Settings.currency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
@ -153,7 +131,8 @@ export class PortfolioController {
|
|||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
portfolioPosition.valueInPercentage =
|
||||||
|
portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
@ -162,27 +141,123 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accounts, hasError, holdings };
|
if (
|
||||||
|
hasDetails === false ||
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
portfolioSummary = nullifyValuesInObject(summary, [
|
||||||
|
'cash',
|
||||||
|
'committedFunds',
|
||||||
|
'currentGrossPerformance',
|
||||||
|
'currentNetPerformance',
|
||||||
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
|
'emergencyFund',
|
||||||
|
'excludedAccountsAndActivities',
|
||||||
|
'fees',
|
||||||
|
'items',
|
||||||
|
'netWorth',
|
||||||
|
'totalBuy',
|
||||||
|
'totalSell'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
holdings[symbol] = {
|
||||||
|
...portfolioPosition,
|
||||||
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||||
|
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
filteredValueInBaseCurrency,
|
||||||
|
filteredValueInPercentage,
|
||||||
|
hasError,
|
||||||
|
holdings,
|
||||||
|
totalValueInBaseCurrency,
|
||||||
|
summary: portfolioSummary
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dividends')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getDividends(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
|
): Promise<PortfolioDividends> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
let dividends = await this.portfolioService.getDividends({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
groupBy,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxDividend = dividends.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
dividends = dividends.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxDividend
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
dividends = dividends.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dividends };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
filterByAccounts,
|
||||||
this.request.user.subscription.type === 'Basic'
|
filterByAssetClasses,
|
||||||
) {
|
filterByTags
|
||||||
throw new HttpException(
|
});
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioServiceStrategy
|
let investments = await this.portfolioService.getInvestments({
|
||||||
.get()
|
dateRange,
|
||||||
.getInvestments(impersonationId);
|
filters,
|
||||||
|
groupBy,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -199,28 +274,81 @@ export class PortfolioController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
investments = investments.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { investments };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPerformance(
|
@Version('2')
|
||||||
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const performanceInformation = await this.portfolioServiceStrategy
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
.get()
|
filterByAccounts,
|
||||||
.getPerformance(impersonationId, range);
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
|
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
|
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
totalInvestment: new Big(totalInvestment)
|
||||||
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
|
.toNumber(),
|
||||||
|
valueInPercentage: new Big(value)
|
||||||
|
.div(performanceInformation.performance.currentValue)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
performanceInformation.performance = nullifyValuesInObject(
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
['currentGrossPerformance', 'currentValue']
|
[
|
||||||
|
'currentGrossPerformance',
|
||||||
|
'currentNetPerformance',
|
||||||
|
'currentValue',
|
||||||
|
'totalInvestment'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
|
(item) => {
|
||||||
|
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,33 +357,30 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioServiceStrategy
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
.get()
|
filterByAccounts,
|
||||||
.getPositions(impersonationId, range);
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
return this.portfolioService.getPositions({
|
||||||
impersonationId ||
|
dateRange,
|
||||||
this.userService.isRestrictedView(this.request.user)
|
filters,
|
||||||
) {
|
impersonationId
|
||||||
result.positions = result.positions.map((position) => {
|
|
||||||
return nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'quantity'
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
@ -276,88 +401,52 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioServiceStrategy
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
.get(true)
|
dateRange: 'max',
|
||||||
.getDetails(access.userId, access.userId);
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
|
impersonationId: access.userId,
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
|
alias: access.alias,
|
||||||
holdings: {}
|
holdings: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
|
||||||
return holding.assetClass === 'EQUITY';
|
|
||||||
})
|
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user?.Settings?.currency ?? baseCurrency
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: portfolioPosition.currency,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: portfolioPosition.markets,
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
value: portfolioPosition.value / totalValue
|
symbol: portfolioPosition.symbol,
|
||||||
|
url: portfolioPosition.url,
|
||||||
|
valueInPercentage: portfolioPosition.value / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getSummary(
|
|
||||||
@Headers('impersonation-id') impersonationId
|
|
||||||
): Promise<PortfolioSummary> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary = await this.portfolioServiceStrategy
|
|
||||||
.get()
|
|
||||||
.getSummary(impersonationId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
summary = nullifyValuesInObject(summary, [
|
|
||||||
'cash',
|
|
||||||
'committedFunds',
|
|
||||||
'currentGrossPerformance',
|
|
||||||
'currentNetPerformance',
|
|
||||||
'currentValue',
|
|
||||||
'dividend',
|
|
||||||
'emergencyFund',
|
|
||||||
'fees',
|
|
||||||
'items',
|
|
||||||
'netWorth',
|
|
||||||
'totalBuy',
|
|
||||||
'totalSell'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ -366,25 +455,13 @@ export class PortfolioController {
|
|||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioServiceStrategy
|
const position = await this.portfolioService.getPosition(
|
||||||
.get()
|
dataSource,
|
||||||
.getPosition(dataSource, impersonationId, symbol);
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
position = nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'orders',
|
|
||||||
'quantity',
|
|
||||||
'value'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,16 +476,19 @@ export class PortfolioController {
|
|||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
const report = await this.portfolioService.getReport(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
for (const rule in report.rules) {
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
if (report.rules[rule]) {
|
||||||
StatusCodes.FORBIDDEN
|
report.rules[rule] = [];
|
||||||
);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -13,17 +14,16 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
exports: [PortfolioServiceStrategy],
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -39,8 +39,6 @@ import { RulesService } from './rules.service';
|
|||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
PortfolioServiceNew,
|
|
||||||
PortfolioServiceStrategy,
|
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { Rule } from '@ghostfolio/api/models/rule';
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -8,7 +9,7 @@ export class RulesService {
|
|||||||
|
|
||||||
public async evaluate<T extends RuleSettings>(
|
public async evaluate<T extends RuleSettings>(
|
||||||
aRules: Rule<T>[],
|
aRules: Rule<T>[],
|
||||||
aUserSettings: { baseCurrency: string }
|
aUserSettings: UserSettings
|
||||||
) {
|
) {
|
||||||
return aRules
|
return aRules
|
||||||
.filter((rule) => {
|
.filter((rule) => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
@ -9,15 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CacheModule.registerAsync({
|
CacheModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => ({
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
|
return <CacheManagerOptions>{
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
port: configurationService.get('REDIS_PORT'),
|
port: configurationService.get('REDIS_PORT'),
|
||||||
store: redisStore,
|
store: redisStore,
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
})
|
};
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
ConfigurationModule
|
ConfigurationModule
|
||||||
],
|
],
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_COUPONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -18,6 +21,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 { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -59,6 +63,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
await this.subscriptionService.createSubscription({
|
await this.subscriptionService.createSubscription({
|
||||||
duration: coupon.duration,
|
duration: coupon.duration,
|
||||||
|
price: 0,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,9 +88,12 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
<string>request.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
@ -93,7 +101,11 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
response.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_STRIPE_CONFIG
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
@ -33,7 +38,9 @@ export class SubscriptionService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
|
cancel_url: `${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: userId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
@ -67,13 +74,16 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public async createSubscription({
|
public async createSubscription({
|
||||||
duration = '1 year',
|
duration = '1 year',
|
||||||
|
price,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
duration?: StringValue;
|
duration?: StringValue;
|
||||||
|
price: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
price,
|
||||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
@ -90,7 +100,21 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.createSubscription({ userId: session.client_reference_id });
|
let subscriptions: SubscriptionInterface[] = [];
|
||||||
|
|
||||||
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
|
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||||
|
|
||||||
|
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||||
|
const price = subscriptions[0]?.price ?? 0;
|
||||||
|
|
||||||
|
await this.createSubscription({
|
||||||
|
price: price - coupon,
|
||||||
|
userId: session.client_reference_id
|
||||||
|
});
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface SymbolItem {
|
export interface SymbolItem extends UniqueAsset {
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
|
||||||
historicalData: HistoricalDataItem[];
|
historicalData: HistoricalDataItem[];
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,6 @@ export class SymbolController {
|
|||||||
* Must be after /lookup
|
* Must be after /lookup
|
||||||
*/
|
*/
|
||||||
@Get(':dataSource/:symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@ -92,10 +91,19 @@ export class SymbolController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.symbolService.getForDate({
|
const result = await this.symbolService.getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result || isEmpty(result)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
@ -6,8 +5,8 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
@ -32,7 +31,7 @@ export class SymbolService {
|
|||||||
]);
|
]);
|
||||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||||
let historicalData: HistoricalDataItem[] = [];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
if (includeHistoricalData > 0) {
|
if (includeHistoricalData > 0) {
|
||||||
@ -55,7 +54,8 @@ export class SymbolService {
|
|||||||
currency,
|
currency,
|
||||||
historicalData,
|
historicalData,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
dataSource: dataGatheringItem.dataSource
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,13 +64,9 @@ export class SymbolService {
|
|||||||
|
|
||||||
public async getForDate({
|
public async getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||||
dataSource: DataSource;
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<IDataProviderHistoricalResponse> {
|
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource, symbol }],
|
[{ dataSource, symbol }],
|
||||||
date,
|
date,
|
||||||
|
7
apps/api/src/app/user/create-user.dto.ts
Normal file
7
apps/api/src/app/user/create-user.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
country?: string;
|
||||||
|
}
|
@ -1,4 +0,0 @@
|
|||||||
export interface Access {
|
|
||||||
alias?: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import { ViewMode } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface UserSettingsParams {
|
|
||||||
currency?: string;
|
|
||||||
userId: string;
|
|
||||||
viewMode?: ViewMode;
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
export interface UserSettings {
|
|
||||||
emergencyFund?: number;
|
|
||||||
locale?: string;
|
|
||||||
isNewCalculationEngine?: boolean;
|
|
||||||
isRestrictedView?: boolean;
|
|
||||||
}
|
|
@ -1,19 +1,58 @@
|
|||||||
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
import type {
|
||||||
|
ColorScheme,
|
||||||
|
DateRange,
|
||||||
|
ViewMode
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
baseCurrency?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
benchmark?: string;
|
||||||
|
|
||||||
|
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
|
||||||
|
@IsOptional()
|
||||||
|
colorScheme?: ColorScheme;
|
||||||
|
|
||||||
|
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||||
|
@IsOptional()
|
||||||
|
dateRange?: DateRange;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isNewCalculationEngine?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
savingsRate?: number;
|
||||||
|
|
||||||
|
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
|
||||||
|
@IsOptional()
|
||||||
|
viewMode?: ViewMode;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
import { ViewMode } from '@prisma/client';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateUserSettingsDto {
|
|
||||||
@IsString()
|
|
||||||
baseCurrency: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
viewMode: ViewMode;
|
|
||||||
}
|
|
@ -1,7 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { User } 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 {
|
||||||
@ -22,19 +20,17 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { User as UserModel } from '@prisma/client';
|
import { User as UserModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { size } from 'lodash';
|
||||||
|
|
||||||
|
import { CreateUserDto } from './create-user.dto';
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
|
||||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly jwtService: JwtService,
|
||||||
private jwtService: JwtService,
|
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
@ -70,24 +66,22 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
public async signupUser(): Promise<UserItem> {
|
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
const isUserSignupEnabled =
|
||||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
await this.propertyService.isUserSignupEnabled();
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
|
||||||
)) as boolean;
|
|
||||||
|
|
||||||
if (isReadOnlyMode) {
|
if (!isUserSignupEnabled) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const hasAdmin = await this.userService.hasAdmin();
|
const hasAdmin = await this.userService.hasAdmin();
|
||||||
|
|
||||||
const { accessToken, id, role } = await this.userService.createUser({
|
const { accessToken, id, role } = await this.userService.createUser({
|
||||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
country: data.country,
|
||||||
|
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -103,6 +97,12 @@ export class UserController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||||
if (
|
if (
|
||||||
|
size(data) === 1 &&
|
||||||
|
(data.benchmark || data.dateRange) &&
|
||||||
|
this.request.user.role === 'DEMO'
|
||||||
|
) {
|
||||||
|
// Allow benchmark or date range change for demo user
|
||||||
|
} else if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
@ -130,33 +130,4 @@ export class UserController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('settings')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.updateUserSettings
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSettings: UserSettingsParams = {
|
|
||||||
currency: data.baseCurrency,
|
|
||||||
userId: this.request.user.id
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasPermission(this.request.user.permissions, permissions.updateViewMode)
|
|
||||||
) {
|
|
||||||
userSettings.viewMode = data.viewMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.userService.updateUserSettings(userSettings);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.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';
|
||||||
|
|
||||||
@ -19,7 +20,8 @@ import { UserService } from './user.service';
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
providers: [UserService]
|
providers: [UserService]
|
||||||
})
|
})
|
||||||
|
@ -2,23 +2,23 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
|
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
User as IUser,
|
||||||
baseCurrency,
|
UserSettings,
|
||||||
locale
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { CreateUserDto } from './create-user.dto';
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@ -26,49 +26,53 @@ const crypto = require('crypto');
|
|||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = 'USD';
|
public static DEFAULT_CURRENCY = 'USD';
|
||||||
|
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
private readonly tagService: TagService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
{
|
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||||
Account,
|
|
||||||
alias,
|
|
||||||
id,
|
|
||||||
permissions,
|
|
||||||
Settings,
|
|
||||||
subscription
|
|
||||||
}: UserWithSettings,
|
|
||||||
aLocale = locale
|
aLocale = locale
|
||||||
): Promise<IUser> {
|
): Promise<IUser> {
|
||||||
const access = await this.prismaService.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
include: {
|
include: {
|
||||||
User: true
|
User: true
|
||||||
},
|
},
|
||||||
orderBy: { User: { alias: 'asc' } },
|
orderBy: { alias: 'asc' },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
|
let tags = await this.tagService.getByUser(id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
tags,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
alias: accessItem.User.alias,
|
alias: accessItem.alias,
|
||||||
id: accessItem.id
|
id: accessItem.id
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accounts: Account,
|
accounts: Account,
|
||||||
settings: {
|
settings: {
|
||||||
...(<UserSettings>Settings.settings),
|
...(<UserSettings>Settings.settings),
|
||||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
||||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
|
|
||||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -86,23 +90,95 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isRestrictedView(aUser: UserWithSettings) {
|
public isRestrictedView(aUser: UserWithSettings) {
|
||||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
return aUser.Settings.settings.isRestrictedView ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async user(
|
public async user(
|
||||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||||
): Promise<UserWithSettings | null> {
|
): Promise<UserWithSettings | null> {
|
||||||
const userFromDatabase = await this.prismaService.user.findUnique({
|
const {
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
accessToken,
|
||||||
|
Account,
|
||||||
|
Analytics,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
Subscription,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt
|
||||||
|
} = await this.prismaService.user.findUnique({
|
||||||
|
include: {
|
||||||
|
Account: true,
|
||||||
|
Analytics: true,
|
||||||
|
Settings: true,
|
||||||
|
Subscription: true
|
||||||
|
},
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
const user: UserWithSettings = userFromDatabase;
|
const user: UserWithSettings = {
|
||||||
|
accessToken,
|
||||||
|
Account,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt,
|
||||||
|
activityCount: Analytics?.activityCount
|
||||||
|
};
|
||||||
|
|
||||||
let currentPermissions = getPermissions(userFromDatabase.role);
|
if (user?.Settings) {
|
||||||
|
if (!user.Settings.settings) {
|
||||||
|
user.Settings.settings = {};
|
||||||
|
}
|
||||||
|
} else if (user) {
|
||||||
|
// Set default settings if needed
|
||||||
|
user.Settings = {
|
||||||
|
settings: {},
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: user?.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
// Set default value for base currency
|
||||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
||||||
|
(user.Settings.settings as UserSettings).baseCurrency =
|
||||||
|
UserService.DEFAULT_CURRENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default value for date range
|
||||||
|
(user.Settings.settings as UserSettings).dateRange =
|
||||||
|
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||||
|
? 'max'
|
||||||
|
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||||
|
|
||||||
|
// Set default value for view mode
|
||||||
|
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||||
|
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
user.subscription =
|
||||||
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Analytics?.activityCount % 25 === 0 &&
|
||||||
|
user.subscription?.type === 'Basic'
|
||||||
|
) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.subscription?.type === 'Premium') {
|
||||||
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
@ -125,36 +201,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.permissions = currentPermissions;
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
|
return account.name;
|
||||||
if (userFromDatabase?.Settings) {
|
|
||||||
if (!userFromDatabase.Settings.currency) {
|
|
||||||
// Set default currency if needed
|
|
||||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
|
||||||
}
|
|
||||||
} else if (userFromDatabase) {
|
|
||||||
// Set default settings if needed
|
|
||||||
userFromDatabase.Settings = {
|
|
||||||
currency: UserService.DEFAULT_CURRENCY,
|
|
||||||
settings: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: userFromDatabase?.id,
|
|
||||||
viewMode: ViewMode.DEFAULT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
user.subscription = this.subscriptionService.getSubscription(
|
|
||||||
userFromDatabase?.Subscription
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user.subscription.type === SubscriptionType.Basic) {
|
|
||||||
user.permissions = user.permissions.filter((permission) => {
|
|
||||||
return permission !== permissions.updateViewMode;
|
|
||||||
});
|
});
|
||||||
user.Settings.viewMode = ViewMode.ZEN;
|
user.permissions = currentPermissions.sort();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -183,7 +233,10 @@ export class UserService {
|
|||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
public async createUser({
|
||||||
|
country,
|
||||||
|
data
|
||||||
|
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
|
||||||
if (!data?.provider) {
|
if (!data?.provider) {
|
||||||
data.provider = 'ANONYMOUS';
|
data.provider = 'ANONYMOUS';
|
||||||
}
|
}
|
||||||
@ -193,19 +246,30 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency,
|
currency: this.baseCurrency,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency
|
settings: {
|
||||||
|
currency: this.baseCurrency
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
await this.prismaService.analytics.create({
|
||||||
|
data: {
|
||||||
|
country,
|
||||||
|
User: { connect: { id: user.id } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (data.provider === 'ANONYMOUS') {
|
if (data.provider === 'ANONYMOUS') {
|
||||||
const accessToken = this.createAccessToken(
|
const accessToken = this.createAccessToken(
|
||||||
user.id,
|
user.id,
|
||||||
@ -274,7 +338,7 @@ export class UserService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userSettings: UserSettings;
|
userSettings: UserSettings;
|
||||||
}) {
|
}) {
|
||||||
const settings = userSettings as Prisma.JsonObject;
|
const settings = userSettings as unknown as Prisma.JsonObject;
|
||||||
|
|
||||||
await this.prismaService.settings.upsert({
|
await this.prismaService.settings.upsert({
|
||||||
create: {
|
create: {
|
||||||
@ -296,33 +360,6 @@ export class UserService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateUserSettings({
|
|
||||||
currency,
|
|
||||||
userId,
|
|
||||||
viewMode
|
|
||||||
}: UserSettingsParams) {
|
|
||||||
await this.prismaService.settings.upsert({
|
|
||||||
create: {
|
|
||||||
currency,
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: userId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewMode
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
currency,
|
|
||||||
viewMode
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
userId: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRandomString(length: number) {
|
private getRandomString(length: number) {
|
||||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
const result = [];
|
const result = [];
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user