Compare commits
793 Commits
Author | SHA1 | Date | |
---|---|---|---|
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e | |||
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
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 |
@ -11,6 +11,5 @@ POSTGRES_USER=user
|
|||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"ignorePatterns": ["**/*"],
|
"ignorePatterns": ["**/*"],
|
||||||
"plugins": ["@nrwl/nx"],
|
"plugins": ["@nx"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@nrwl/nx/enforce-module-boundaries": [
|
"@nx/enforce-module-boundaries": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"enforceBuildableLibDependency": true,
|
"enforceBuildableLibDependency": true,
|
||||||
@ -23,12 +23,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,5 +113,6 @@
|
|||||||
"radix": "error"
|
"radix": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"extends": [null, "plugin:storybook/recommended"]
|
||||||
}
|
}
|
||||||
|
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
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).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
|
- Cloud or Self-hosted
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
4
.github/workflows/build-code.yml
vendored
4
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 16
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@ -33,4 +33,4 @@ jobs:
|
|||||||
run: yarn test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:all
|
run: yarn build:production
|
||||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.output.labels }}
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
stories: [],
|
|
||||||
addons: ['@storybook/addon-essentials']
|
|
||||||
// uncomment the property below if you want to apply some webpack config globally
|
|
||||||
// webpackFinal: async (config, { configType }) => {
|
|
||||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
|
||||||
|
|
||||||
// // Return the altered config
|
|
||||||
// return config;
|
|
||||||
// },
|
|
||||||
};
|
|
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.base.json",
|
|
||||||
"exclude": [
|
|
||||||
"../**/*.spec.js",
|
|
||||||
"../**/*.spec.ts",
|
|
||||||
"../**/*.spec.tsx",
|
|
||||||
"../**/*.spec.jsx"
|
|
||||||
],
|
|
||||||
"include": ["../**/*"]
|
|
||||||
}
|
|
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Debug Jest File",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
|
||||||
"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"
|
||||||
],
|
],
|
||||||
|
"console": "internalConsole",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "internalConsole"
|
"name": "Debug Jest",
|
||||||
|
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"envFile": "${workspaceFolder}/.env",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Launch Program",
|
|
||||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
|
||||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
|
||||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
|
||||||
"autoAttachChildProcesses": true,
|
"autoAttachChildProcesses": true,
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"cwd": "${workspaceFolder}/apps/api",
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"name": "Debug API",
|
||||||
|
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||||
|
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
],
|
],
|
||||||
"console": "integratedTerminal"
|
"type": "node"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
1561
CHANGELOG.md
1561
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
37
DEVELOPMENT.md
Normal file
37
DEVELOPMENT.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
### Rebase
|
||||||
|
|
||||||
|
`git rebase -i --autosquash main`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Nx
|
||||||
|
|
||||||
|
#### Upgrade
|
||||||
|
|
||||||
|
1. Run `yarn nx migrate latest`
|
||||||
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
|
1. Run `yarn nx migrate --run-migrations`
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
|
Run `yarn database:push`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
|
#### Create schema migration
|
||||||
|
|
||||||
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
11
Dockerfile
11
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:16-slim 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
|
||||||
@ -25,7 +25,6 @@ RUN yarn install
|
|||||||
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
|
||||||
@ -34,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:all
|
RUN yarn build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
@ -51,12 +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:16-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:prod" ]
|
CMD [ "yarn", "start:production" ]
|
||||||
|
140
README.md
140
README.md
@ -1,37 +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</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/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://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
[](#contributing)
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
</p>
|
|
||||||
|
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**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">
|
||||||
|
|
||||||
|
[<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 align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
|
||||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
@ -46,23 +42,25 @@ 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 2022
|
- 🙅 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 align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
@ -79,14 +77,19 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[<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
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `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 |
|
| `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) |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
@ -104,7 +107,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
- Basic knowledge of Docker
|
- Basic knowledge of Docker
|
||||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- Local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
#### a. Run environment
|
#### a. Run environment
|
||||||
|
|
||||||
@ -123,13 +127,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Setup
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
|
||||||
|
|
||||||
|
1. Open http://localhost:3333 in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
@ -137,40 +138,42 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
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 `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
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. 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. 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_
|
||||||
|
|
||||||
@ -190,20 +193,24 @@ Run `yarn test`
|
|||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
|
### Authorization: Bearer Token
|
||||||
|
|
||||||
|
Set the header for each request as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Authorization": "Bearer eyJh..."
|
||||||
|
```
|
||||||
|
|
||||||
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
#### Request
|
#### Request
|
||||||
|
|
||||||
`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
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -215,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
|
||||||
}
|
}
|
||||||
@ -226,6 +233,7 @@ Set the header as follows:
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | -------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
@ -254,16 +262,22 @@ Set the header as follows:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Community Projects
|
||||||
|
|
||||||
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
|
|
||||||
|
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||||
|
|
||||||
## 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](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 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).
|
||||||
|
395
angular.json
395
angular.json
@ -1,395 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"projects": {
|
|
||||||
"api": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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.ts",
|
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
|
||||||
"outputs": ["coverage/apps/api"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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": [
|
|
||||||
{
|
|
||||||
"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": "./../"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../assets/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
|
||||||
"scripts": ["node_modules/marked/marked.min.js"],
|
|
||||||
"vendorChunk": true,
|
|
||||||
"extractLicenses": false,
|
|
||||||
"buildOptimizer": false,
|
|
||||||
"sourceMap": true,
|
|
||||||
"optimization": false,
|
|
||||||
"namedChunks": true
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"development-de": {
|
|
||||||
"baseHref": "/de/",
|
|
||||||
"localize": ["de"]
|
|
||||||
},
|
|
||||||
"development-en": {
|
|
||||||
"baseHref": "/en/",
|
|
||||||
"localize": ["en"]
|
|
||||||
},
|
|
||||||
"development-es": {
|
|
||||||
"baseHref": "/es/",
|
|
||||||
"localize": ["es"]
|
|
||||||
},
|
|
||||||
"development-it": {
|
|
||||||
"baseHref": "/it/",
|
|
||||||
"localize": ["it"]
|
|
||||||
},
|
|
||||||
"development-nl": {
|
|
||||||
"baseHref": "/nl/",
|
|
||||||
"localize": ["nl"]
|
|
||||||
},
|
|
||||||
"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": {
|
|
||||||
"development-de": {
|
|
||||||
"browserTarget": "client:build:development-de"
|
|
||||||
},
|
|
||||||
"development-en": {
|
|
||||||
"browserTarget": "client:build:development-en"
|
|
||||||
},
|
|
||||||
"development-es": {
|
|
||||||
"browserTarget": "client:build:development-es"
|
|
||||||
},
|
|
||||||
"development-it": {
|
|
||||||
"browserTarget": "client:build:development-it"
|
|
||||||
},
|
|
||||||
"development-nl": {
|
|
||||||
"browserTarget": "client:build:development-nl"
|
|
||||||
},
|
|
||||||
"production": {
|
|
||||||
"browserTarget": "client:build:production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"extract-i18n": {
|
|
||||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
|
||||||
"options": {
|
|
||||||
"browserTarget": "client:build",
|
|
||||||
"includeContext": true,
|
|
||||||
"outputPath": "src/locales",
|
|
||||||
"targetFiles": [
|
|
||||||
"messages.de.xlf",
|
|
||||||
"messages.es.xlf",
|
|
||||||
"messages.it.xlf",
|
|
||||||
"messages.nl.xlf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@nrwl/jest:jest",
|
|
||||||
"options": {
|
|
||||||
"jestConfig": "apps/client/jest.config.ts",
|
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
|
||||||
"outputs": ["coverage/apps/client"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"i18n": {
|
|
||||||
"locales": {
|
|
||||||
"de": {
|
|
||||||
"baseHref": "/de/",
|
|
||||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"baseHref": "/es/",
|
|
||||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"baseHref": "/it/",
|
|
||||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
|
||||||
},
|
|
||||||
"nl": {
|
|
||||||
"baseHref": "/nl/",
|
|
||||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sourceLocale": "en"
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"client-e2e": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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.ts",
|
|
||||||
"passWithNoTests": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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.ts",
|
|
||||||
"passWithNoTests": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@nrwl/linter:eslint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storybook": {
|
|
||||||
"builder": "@storybook/angular:start-storybook",
|
|
||||||
"options": {
|
|
||||||
"port": 4400,
|
|
||||||
"configDir": "libs/ui/.storybook",
|
|
||||||
"browserTarget": "ui:build-storybook",
|
|
||||||
"compodoc": false
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"ci": {
|
|
||||||
"quiet": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"build-storybook": {
|
|
||||||
"builder": "@storybook/angular:build-storybook",
|
|
||||||
"outputs": ["{options.outputPath}"],
|
|
||||||
"options": {
|
|
||||||
"outputDir": "dist/storybook/ui",
|
|
||||||
"configDir": "libs/ui/.storybook",
|
|
||||||
"browserTarget": "ui:build-storybook",
|
|
||||||
"compodoc": false
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"ci": {
|
|
||||||
"quiet": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
},
|
|
||||||
"ui-e2e": {
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,13 +2,14 @@
|
|||||||
export default {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {},
|
||||||
'ts-jest': {
|
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.[tj]s$': 'ts-jest'
|
'^.+\\.[tj]s$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
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": "@nx/js:node",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "api:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nrwl/linter:eslint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/jest:jest",
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
|
"passWithNoTests": true
|
||||||
|
},
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
@ -8,4 +8,8 @@ export class CreateAccessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
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 {
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
nullifyValuesInObject,
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
nullifyValuesInObjects
|
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
|
||||||
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 {
|
import type {
|
||||||
@ -22,7 +19,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';
|
||||||
@ -39,8 +37,7 @@ export class AccountController {
|
|||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -85,87 +82,36 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let accountsWithAggregations =
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
userId: impersonationUserId || this.request.user.id,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
withExcludedAccounts: true
|
||||||
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'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountWithValue> {
|
): Promise<AccountWithValue> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
filters: [{ id, type: 'ACCOUNT' }],
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
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.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
|
|||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
exports: [AccountService],
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountBalanceModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async account(
|
public async account({
|
||||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
id_userId
|
||||||
): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
return this.prismaService.account.findUnique({
|
const { id, userId } = id_userId;
|
||||||
where: accountWhereUniqueInput
|
|
||||||
|
const [account] = await this.accounts({
|
||||||
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async accountWithOrders(
|
public async accountWithOrders(
|
||||||
@ -50,9 +56,11 @@ export class AccountService {
|
|||||||
Platform?: Platform;
|
Platform?: Platform;
|
||||||
})[]
|
})[]
|
||||||
> {
|
> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prismaService.account.findMany({
|
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||||
|
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -60,15 +68,36 @@ export class AccountService {
|
|||||||
take,
|
take,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||||
|
|
||||||
|
delete account.balances;
|
||||||
|
|
||||||
|
return account;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
data: Prisma.AccountCreateInput,
|
data: Prisma.AccountCreateInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prismaService.account.create({
|
const account = await this.prismaService.account.create({
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { id: account.id, userId: aUserId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccount(
|
public async deleteAccount(
|
||||||
@ -167,9 +196,65 @@ export class AccountService {
|
|||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: where.id_userId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: <number>data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
date: Date;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountInCurrencyOfAccount =
|
||||||
|
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
currencyOfAccount,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (amountInCurrencyOfAccount) {
|
||||||
|
await this.accountBalanceService.createAccountBalance({
|
||||||
|
date,
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,17 +7,30 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType?: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExcluded?: boolean;
|
isExcluded?: boolean;
|
||||||
|
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TransferBalanceDto {
|
||||||
|
@IsString()
|
||||||
|
accountIdFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
accountIdTo: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,14 +7,23 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType?: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,18 +1,25 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
Filter
|
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 {
|
||||||
|
MarketDataPreset,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -21,18 +28,21 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
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';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate, parseISO } 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')
|
||||||
@ -97,16 +107,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
@ -128,16 +143,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -158,14 +178,17 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -210,7 +233,7 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -229,7 +252,12 @@ export class AdminController {
|
|||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -254,7 +282,14 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.adminService.getMarketData(filters);
|
return this.adminService.getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -298,12 +333,13 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data: { ...data, dataSource },
|
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||||
where: {
|
where: {
|
||||||
date_symbol: {
|
dataSource_date_symbol: {
|
||||||
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
@ -311,6 +347,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
@ -332,6 +390,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(
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
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 { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -14,25 +21,55 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
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 dataProviderService: DataProviderService,
|
||||||
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 addAssetProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -45,15 +82,15 @@ export class AdminService {
|
|||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== this.baseCurrency;
|
return currency !== DEFAULT_CURRENCY;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
label1: this.baseCurrency,
|
label1: DEFAULT_CURRENCY,
|
||||||
label2: currency,
|
label2: currency,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
this.baseCurrency,
|
DEFAULT_CURRENCY,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@ -61,13 +98,39 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
public async getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip,
|
||||||
|
take = Number.MAX_SAFE_INTEGER
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
presetId?: MarketDataPreset;
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminMarketData> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||||
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
if (presetId === 'CURRENCIES') {
|
||||||
|
return this.getMarketDataForCurrencies();
|
||||||
|
} else if (
|
||||||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
|
presetId === 'ETF_WITHOUT_SECTORS'
|
||||||
|
) {
|
||||||
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
|
}
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
(filter) => {
|
||||||
@ -75,47 +138,40 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
|
||||||
|
|
||||||
if (filtersByAssetSubClass) {
|
if (filtersByAssetSubClass) {
|
||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
} else {
|
|
||||||
currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
countriesCount: 0,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
if (sortColumn) {
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
|
if (sortColumn === 'activitiesCount') {
|
||||||
|
orderBy = {
|
||||||
|
Order: {
|
||||||
|
_count: sortDirection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
@ -127,37 +183,64 @@ export class AdminService {
|
|||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
).map((symbolProfile) => {
|
this.prismaService.symbolProfile.count({ where })
|
||||||
const countriesCount = symbolProfile.countries
|
]);
|
||||||
? Object.keys(symbolProfile.countries).length
|
|
||||||
: 0;
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
|
||||||
marketDataItem.symbol === symbolProfile.symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
const sectorsCount = symbolProfile.sectors
|
|
||||||
? Object.keys(symbolProfile.sectors).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
let marketData = assetProfiles.map(
|
||||||
countriesCount,
|
({
|
||||||
marketDataItemCount,
|
_count,
|
||||||
sectorsCount,
|
assetClass,
|
||||||
activityCount: symbolProfile._count.Order,
|
assetSubClass,
|
||||||
assetClass: symbolProfile.assetClass,
|
comment,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
countries,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
Order,
|
||||||
symbol: symbolProfile.symbol
|
sectors,
|
||||||
};
|
symbol
|
||||||
});
|
}) => {
|
||||||
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (presetId) {
|
||||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||||
|
marketData = marketData.filter(({ countriesCount }) => {
|
||||||
|
return countriesCount === 0;
|
||||||
|
});
|
||||||
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||||
|
marketData = marketData.filter(({ sectorsCount }) => {
|
||||||
|
return sectorsCount === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count = marketData.length;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
count,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,8 +248,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'
|
||||||
},
|
},
|
||||||
@ -175,9 +264,42 @@ export class AdminService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData,
|
||||||
|
assetProfile: assetProfile ?? {
|
||||||
|
symbol,
|
||||||
|
currency: '-'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
|
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;
|
||||||
|
|
||||||
@ -187,20 +309,67 @@ export class AdminService {
|
|||||||
response = await this.propertyService.delete({ key });
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||||
|
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||||
|
} else if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['dataSource', 'symbol']
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol,
|
||||||
|
assetClass: 'CASH',
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { marketData, count: marketData.length };
|
||||||
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
let orderBy: any = {
|
||||||
orderBy: {
|
createdAt: 'desc'
|
||||||
|
};
|
||||||
|
let where;
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
where = {
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
|
orderBy,
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
@ -208,6 +377,7 @@ export class AdminService {
|
|||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
|
country: true,
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -215,19 +385,16 @@ export class AdminService {
|
|||||||
id: true,
|
id: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30
|
||||||
where: {
|
|
||||||
NOT: {
|
|
||||||
Analytics: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, 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
|
||||||
|
? Analytics.activityCount / daysSinceRegistration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
@ -241,7 +408,8 @@ export class AdminService {
|
|||||||
id,
|
id,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
lastActivity: Analytics.updatedAt,
|
country: Analytics?.country,
|
||||||
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -23,14 +23,11 @@ export class QueueService {
|
|||||||
}: {
|
}: {
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
for (const statusItem of status) {
|
||||||
|
await this.dataGatheringQueue.clean(
|
||||||
for (const job of jobs) {
|
300,
|
||||||
try {
|
statusItem === 'waiting' ? 'wait' : statusItem
|
||||||
await job.remove();
|
);
|
||||||
} catch (error) {
|
|
||||||
Logger.warn(error, 'QueueService');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,18 +41,23 @@ export class QueueService {
|
|||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
const jobsWithState = await Promise.all(
|
const jobsWithState = await Promise.all(
|
||||||
jobs.slice(0, limit).map(async (job) => {
|
jobs
|
||||||
return {
|
.filter((job) => {
|
||||||
attemptsMade: job.attemptsMade + 1,
|
return job;
|
||||||
data: job.data,
|
})
|
||||||
finishedOn: job.finishedOn,
|
.slice(0, limit)
|
||||||
id: job.id,
|
.map(async (job) => {
|
||||||
name: job.name,
|
return {
|
||||||
stacktrace: job.stacktrace,
|
attemptsMade: job.attemptsMade + 1,
|
||||||
state: await job.getState(),
|
data: job.data,
|
||||||
timestamp: job.timestamp
|
finishedOn: job.finishedOn,
|
||||||
};
|
id: job.id,
|
||||||
})
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
18
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
18
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
symbolMapping?: {
|
||||||
|
[dataProvider: string]: string;
|
||||||
|
};
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,35 +1,45 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
SUPPORTED_LANGUAGE_CODES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
|
||||||
import { 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 { 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 { HealthModule } from './health/health.module';
|
||||||
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 { PlatformModule } from './platform/platform.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
|
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||||
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
|
import { TagModule } from './tag/tag.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -43,7 +53,7 @@ import { UserModule } from './user/user.module';
|
|||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT, 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -52,41 +62,51 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
serveStaticOptions: {
|
exclude: ['/api*', '/sitemap.xml'],
|
||||||
/*etag: false // Disable etag header to fix PWA
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
if (path.includes('ngsw.json')) {
|
|
||||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
|
||||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
|
||||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
rootPath: join(__dirname, '..', 'client'),
|
rootPath: join(__dirname, '..', 'client'),
|
||||||
exclude: ['/api*']
|
serveStaticOptions: {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
if (res.req?.path === '/') {
|
||||||
|
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = res.req.headers['accept-language']
|
||||||
|
.split(',')[0]
|
||||||
|
.split('-')[0];
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||||
|
languageCode = code;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.set('Location', `/${languageCode}`);
|
||||||
|
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
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,7 +1,7 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
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/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
@ -16,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';
|
||||||
@ -32,13 +33,32 @@ export class AuthController {
|
|||||||
private readonly webAuthService: WebAuthService
|
private readonly webAuthService: WebAuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(
|
public async accessTokenLoginGet(
|
||||||
@Param('accessToken') accessToken: string
|
@Param('accessToken') accessToken: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken =
|
||||||
|
await this.authService.validateAnonymousLogin(accessToken);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('anonymous')
|
||||||
|
public async accessTokenLogin(
|
||||||
|
@Body() body: { accessToken: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
body.accessToken
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
@ -58,18 +78,21 @@ 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(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
@ -77,13 +100,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('internet-identity/:principalId')
|
@Post('internet-identity')
|
||||||
public async internetIdentityLogin(
|
public async internetIdentityLogin(
|
||||||
@Param('principalId') principalId: string
|
@Body() body: { principalId: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
principalId
|
body.principalId
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -2,8 +2,9 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
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/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/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,5 +1,6 @@
|
|||||||
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/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 { Provider } from '@prisma/client';
|
||||||
@ -11,6 +12,7 @@ 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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -50,10 +52,19 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled || true) {
|
||||||
|
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({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId: principalId
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +89,19 @@ 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({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId
|
provider,
|
||||||
|
thirdPartyId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,33 +1,46 @@
|
|||||||
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/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
public constructor(
|
public constructor(
|
||||||
readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
passReqToCallback: true,
|
||||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate({ id }: { id: string }) {
|
public async validate(request: Request, { id }: { id: string }) {
|
||||||
try {
|
try {
|
||||||
|
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||||
const user = await this.userService.user({ id });
|
const user = await this.userService.user({ id });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.prismaService.analytics.upsert({
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
create: { User: { connect: { id: user.id } } },
|
const country =
|
||||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
where: { userId: user.id }
|
|
||||||
});
|
await this.prismaService.analytics.upsert({
|
||||||
|
create: { country, User: { connect: { id: user.id } } },
|
||||||
|
update: {
|
||||||
|
country,
|
||||||
|
activityCount: { increment: 1 },
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
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/configuration.service';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
|
@ -1,24 +1,114 @@
|
|||||||
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 {
|
import type {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@Controller('benchmark')
|
@Controller('benchmark')
|
||||||
export class BenchmarkController {
|
export class BenchmarkController {
|
||||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteBenchmark(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@ -30,8 +120,8 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BenchmarkController } from './benchmark.controller';
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
|||||||
let benchmarkService: BenchmarkService;
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
benchmarkService = new BenchmarkService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculateChangeInPercentage', async () => {
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,6 +30,7 @@ export class BenchmarkService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly symbolProfileService: SymbolProfileService,
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
@ -62,11 +66,11 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes(
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
})
|
||||||
);
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
@ -116,9 +120,9 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
((await this.propertyService.getByKey(
|
||||||
symbolProfileId: string;
|
PROPERTY_BENCHMARKS
|
||||||
}[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
).map(({ symbolProfileId }) => {
|
||||||
return symbolProfileId;
|
return symbolProfileId;
|
||||||
});
|
});
|
||||||
@ -204,6 +208,80 @@ export class BenchmarkService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks.push({ symbolProfileId: assetProfile.id });
|
||||||
|
|
||||||
|
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId !== assetProfile.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
}
|
}
|
||||||
|
10
apps/api/src/app/cache/cache.module.ts
vendored
10
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,10 @@
|
|||||||
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/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
43
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
43
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
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 = parseISO(dateString);
|
||||||
|
|
||||||
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exchangeRate) {
|
||||||
|
return { marketPrice: exchangeRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
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/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 {}
|
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
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<number> {
|
||||||
|
const [currency1, currency2] = symbol.split('-');
|
||||||
|
|
||||||
|
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
1,
|
||||||
|
currency1,
|
||||||
|
currency2,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,9 @@
|
|||||||
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
PrismaModule,
|
OrderModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
private readonly orderService: OrderService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
@ -14,19 +18,30 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
let activities = await this.prismaService.order.findMany({
|
const accounts = (
|
||||||
|
await this.accountService.accounts({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
})
|
||||||
|
).map(
|
||||||
|
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||||
|
return {
|
||||||
|
balance,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
id,
|
||||||
|
isExcluded,
|
||||||
|
name,
|
||||||
|
platformId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let activities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
|
||||||
accountId: true,
|
|
||||||
comment: true,
|
|
||||||
date: true,
|
|
||||||
fee: true,
|
|
||||||
id: true,
|
|
||||||
quantity: true,
|
|
||||||
SymbolProfile: true,
|
|
||||||
type: true,
|
|
||||||
unitPrice: true
|
|
||||||
},
|
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,6 +53,7 @@ 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,
|
||||||
@ -61,7 +77,13 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol:
|
||||||
|
type === 'FEE' ||
|
||||||
|
type === 'INTEREST' ||
|
||||||
|
type === 'ITEM' ||
|
||||||
|
type === 'LIABILITY'
|
||||||
|
? SymbolProfile.name
|
||||||
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,146 +0,0 @@
|
|||||||
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 { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FrontendMiddleware implements NestMiddleware {
|
|
||||||
public indexHtmlDe = '';
|
|
||||||
public indexHtmlEn = '';
|
|
||||||
public indexHtmlEs = '';
|
|
||||||
public indexHtmlIt = '';
|
|
||||||
public indexHtmlNl = '';
|
|
||||||
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.indexHtmlIt = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlNl = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('nl'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public use(req: Request, res: Response, next: NextFunction) {
|
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
|
||||||
|
|
||||||
if (
|
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
|
||||||
} else if (
|
|
||||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
|
||||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
req.path.startsWith('/api/') ||
|
|
||||||
this.isFileRequest(req.url) ||
|
|
||||||
!this.isProduction
|
|
||||||
) {
|
|
||||||
// Skip
|
|
||||||
next();
|
|
||||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
|
||||||
res.send(
|
|
||||||
this.interpolate(this.indexHtmlDe, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'de',
|
|
||||||
path: req.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
|
||||||
res.send(
|
|
||||||
this.interpolate(this.indexHtmlEs, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'es',
|
|
||||||
path: req.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
|
||||||
res.send(
|
|
||||||
this.interpolate(this.indexHtmlIt, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'it',
|
|
||||||
path: req.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
|
||||||
res.send(
|
|
||||||
this.interpolate(this.indexHtmlNl, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'nl',
|
|
||||||
path: req.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
res.send(
|
|
||||||
this.interpolate(this.indexHtmlEn, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
|
||||||
path: req.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;
|
|
||||||
}
|
|
||||||
}
|
|
56
apps/api/src/app/health/health.controller.ts
Normal file
56
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-enhancer/:name')
|
||||||
|
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||||
|
const hasResponse =
|
||||||
|
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('data-provider/:dataSource')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getHealthOfDataProvider(
|
||||||
|
@Param('dataSource') dataSource: DataSource
|
||||||
|
) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResponse =
|
||||||
|
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
apps/api/src/app/health/health.module.ts
Normal file
14
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||||
|
providers: [HealthService]
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
20
apps/api/src/app/health/health.service.ts
Normal file
20
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataEnhancerService: DataEnhancerService,
|
||||||
|
private readonly dataProviderService: DataProviderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataEnhancer(aName: string) {
|
||||||
|
return this.dataEnhancerService.enhance(aName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
|
}
|
||||||
|
}
|
@ -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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
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/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,8 +35,19 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async 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
|
||||||
@ -45,12 +65,19 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
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({
|
||||||
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
activities: importData.activities,
|
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);
|
||||||
|
|
||||||
@ -63,4 +90,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,15 @@
|
|||||||
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 { PlatformModule } from '@ghostfolio/api/app/platform/platform.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/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -19,9 +23,13 @@ import { ImportService } from './import.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -1,149 +1,594 @@
|
|||||||
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,
|
||||||
|
ActivityError
|
||||||
|
} 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 { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
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 { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
|
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 dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
|
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();
|
||||||
|
|
||||||
|
const isDuplicate = orders.some((activity) => {
|
||||||
|
return (
|
||||||
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
|
isSameDay(activity.date, parseDate(dateString)) &&
|
||||||
|
activity.quantity === quantity &&
|
||||||
|
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||||
|
activity.type === 'DIVIDEND' &&
|
||||||
|
activity.unitPrice === marketPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const error: ActivityError = isDuplicate
|
||||||
|
? { code: 'IS_DUPLICATE' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account,
|
||||||
|
error,
|
||||||
|
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,
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<Activity[]> {
|
||||||
for (const activity of activities) {
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
if (!activity.dataSource) {
|
|
||||||
if (activity.type === 'ITEM') {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
activity.dataSource = 'MANUAL';
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
} else {
|
this.accountService.accounts({
|
||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
where: {
|
||||||
|
id: {
|
||||||
|
in: accountsDto.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.platformService.getPlatforms()
|
||||||
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
|
...account,
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingPlatforms.some(({ id }) => {
|
||||||
|
return id === platformId;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
accountObject = {
|
||||||
|
...accountObject,
|
||||||
|
Platform: { connect: { id: platformId } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await this.accountService.createAccount(
|
||||||
|
accountObject,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the new to old account ID mappings for updating activities
|
||||||
|
if (accountWithSameId && oldAccountId) {
|
||||||
|
accountIdMapping[oldAccountId] = newAccount.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({
|
for (const activity of activitiesDto) {
|
||||||
activities,
|
if (!activity.dataSource) {
|
||||||
maxActivitiesToImport,
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
|
activity.dataSource = DataSource.MANUAL;
|
||||||
|
} else {
|
||||||
|
activity.dataSource =
|
||||||
|
this.dataProviderService.getDataSourceForImport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetProfiles = await this.validateActivities({
|
||||||
|
activitiesDto,
|
||||||
|
maxActivitiesToImport
|
||||||
|
});
|
||||||
|
|
||||||
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
({ id, name }) => {
|
||||||
return account.id;
|
return { id, name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const {
|
if (isDryRun) {
|
||||||
accountId,
|
accountsDto.forEach(({ id, name }) => {
|
||||||
comment,
|
accounts.push({ id, name });
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
date,
|
|
||||||
fee,
|
|
||||||
quantity,
|
|
||||||
symbol,
|
|
||||||
type,
|
|
||||||
unitPrice
|
|
||||||
} of activities) {
|
|
||||||
await this.orderService.createOrder({
|
|
||||||
comment,
|
|
||||||
fee,
|
|
||||||
quantity,
|
|
||||||
type,
|
|
||||||
unitPrice,
|
|
||||||
userId,
|
|
||||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
|
||||||
date: parseISO(<string>(<unknown>date)),
|
|
||||||
SymbolProfile: {
|
|
||||||
connectOrCreate: {
|
|
||||||
create: {
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource_symbol: {
|
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
User: { connect: { id: userId } }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async validateActivities({
|
const activities: Activity[] = [];
|
||||||
activities,
|
|
||||||
maxActivitiesToImport,
|
for (let [
|
||||||
userId
|
index,
|
||||||
}: {
|
{
|
||||||
activities: Partial<CreateOrderDto>[];
|
accountId,
|
||||||
maxActivitiesToImport: number;
|
comment,
|
||||||
userId: string;
|
date,
|
||||||
}) {
|
error,
|
||||||
if (activities?.length > maxActivitiesToImport) {
|
fee,
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
quantity,
|
||||||
|
SymbolProfile,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}
|
||||||
|
] of activitiesExtendedWithErrors.entries()) {
|
||||||
|
const assetProfile = assetProfiles[
|
||||||
|
getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
})
|
||||||
|
] ?? {
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
url,
|
||||||
|
updatedAt
|
||||||
|
} = assetProfile;
|
||||||
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
|
return id === accountId;
|
||||||
|
});
|
||||||
|
|
||||||
|
let order:
|
||||||
|
| OrderWithAccount
|
||||||
|
| (Omit<OrderWithAccount, 'Account'> & {
|
||||||
|
Account?: { id: string; name: string };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||||
|
// Convert the unit price and fee to the asset currency if the imported
|
||||||
|
// activity is in a different currency
|
||||||
|
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unitPrice) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index} historical exchange rate at ${format(
|
||||||
|
date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} is not available from "${SymbolProfile.currency}" to "${
|
||||||
|
assetProfile.currency
|
||||||
|
}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
fee,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
order = {
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: validatedAccount?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
id: uuidv4(),
|
||||||
|
isDraft: isAfter(date, endOfToday()),
|
||||||
|
SymbolProfile: {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
updatedAt,
|
||||||
|
url,
|
||||||
|
comment: assetProfile.comment
|
||||||
|
},
|
||||||
|
Account: validatedAccount,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
order = await this.orderService.createOrder({
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: validatedAccount?.id,
|
||||||
|
SymbolProfile: {
|
||||||
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateAccountBalance: false,
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
|
activities.push({
|
||||||
|
...order,
|
||||||
|
error,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
fee,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
// @ts-ignore
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activities.sort((activity1, activity2) => {
|
||||||
|
return Number(activity1.date) - Number(activity2.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
// Gather symbol data in the background, if not dry run
|
||||||
|
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbols(
|
||||||
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<Partial<Activity>[]> {
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
include: { SymbolProfile: true },
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return activitiesDto.map(
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
date: dateString,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}) => {
|
||||||
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
|
return (
|
||||||
|
activity.SymbolProfile.currency === currency &&
|
||||||
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
|
isSameDay(activity.date, date) &&
|
||||||
|
activity.fee === fee &&
|
||||||
|
activity.quantity === quantity &&
|
||||||
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
|
activity.type === type &&
|
||||||
|
activity.unitPrice === unitPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const error: ActivityError = isDuplicate
|
||||||
|
? { code: 'IS_DUPLICATE' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
error,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetClass: null,
|
||||||
|
assetSubClass: null,
|
||||||
|
comment: null,
|
||||||
|
countries: null,
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isin: null,
|
||||||
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
activitiesDto,
|
||||||
|
maxActivitiesToImport
|
||||||
|
}: {
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
|
}) {
|
||||||
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetProfiles: {
|
||||||
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const uniqueActivitiesDto = uniqBy(
|
||||||
|
activitiesDto,
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, symbol }
|
||||||
] of activities.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
const duplicateActivity = existingActivities.find((activity) => {
|
|
||||||
return (
|
|
||||||
activity.SymbolProfile.currency === currency &&
|
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
|
||||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
|
||||||
activity.fee === fee &&
|
|
||||||
activity.quantity === quantity &&
|
|
||||||
activity.SymbolProfile.symbol === symbol &&
|
|
||||||
activity.type === type &&
|
|
||||||
activity.unitPrice === unitPrice
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (duplicateActivity) {
|
|
||||||
throw new Error(`activities.${index} is a duplicate activity`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const assetProfile = (
|
||||||
{ dataSource, symbol }
|
await this.dataProviderService.getAssetProfiles([
|
||||||
]);
|
{ dataSource, symbol }
|
||||||
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
if (quotes[symbol] === undefined) {
|
if (!assetProfile?.name) {
|
||||||
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 &&
|
||||||
|
!this.exchangeRateDataService.hasCurrencyPair(
|
||||||
|
currency,
|
||||||
|
assetProfile.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}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return assetProfiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -26,11 +28,12 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PlatformModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule
|
TagModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
})
|
})
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.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 { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEFAULT_CURRENCY,
|
||||||
|
DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
@ -14,18 +19,22 @@ import {
|
|||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
encodeDataSource,
|
encodeDataSource,
|
||||||
extractNumberFromString
|
extractNumberFromString
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
InfoItem,
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
Statistics,
|
||||||
|
Subscription
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -36,18 +45,18 @@ export class InfoService {
|
|||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly platformService: PlatformService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly tagService: TagService
|
private readonly tagService: TagService,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = await this.prismaService.platform.findMany({
|
const platforms = await this.platformService.getPlatforms({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' }
|
||||||
select: { id: true, name: true }
|
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
@ -58,9 +67,7 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
|
||||||
) {
|
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
);
|
);
|
||||||
@ -71,10 +78,6 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
|
||||||
globalPermissions.push(permissions.enableImport);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
@ -92,6 +95,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,29 +110,40 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (isUserSignupEnabled) {
|
||||||
|
globalPermissions.push(permissions.createUserAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
|
||||||
|
await Promise.all([
|
||||||
|
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
|
this.getDemoAuthToken(),
|
||||||
|
this.getStatistics(),
|
||||||
|
this.getSubscriptions(),
|
||||||
|
this.tagService.get()
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
|
benchmarks,
|
||||||
|
demoAuthToken,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
|
statistics,
|
||||||
|
subscriptions,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
tags,
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
|
||||||
statistics: await this.getStatistics(),
|
|
||||||
subscriptions: await this.getSubscriptions(),
|
|
||||||
tags: await this.tagService.get()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countActiveUsers(aDays: number) {
|
private async countActiveUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
Analytics: {
|
|
||||||
updatedAt: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -147,20 +165,24 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { pull_count } = await got(
|
||||||
}
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { pull_count } = await get();
|
|
||||||
return pull_count;
|
return pull_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - DockerHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -168,16 +190,18 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
'https://github.com/ghostfolio/ghostfolio',
|
|
||||||
'GET',
|
|
||||||
'string',
|
|
||||||
200,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = await get();
|
setTimeout(() => {
|
||||||
const $ = cheerio.load(html);
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
return extractNumberFromString(
|
return extractNumberFromString(
|
||||||
$(
|
$(
|
||||||
@ -185,7 +209,7 @@ export class InfoService {
|
|||||||
).text()
|
).text()
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -193,30 +217,31 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { stargazers_count } = await got(
|
||||||
}
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { stargazers_count } = await get();
|
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countNewUsers(aDays: number) {
|
private async countNewUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -240,10 +265,18 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private async getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
const demoUserId = (await this.propertyService.getByKey(
|
||||||
id: DEMO_USER_ID
|
PROPERTY_DEMO_USER_ID
|
||||||
});
|
)) as string;
|
||||||
|
|
||||||
|
if (demoUserId) {
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: demoUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
@ -271,6 +304,7 @@ export class InfoService {
|
|||||||
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();
|
||||||
|
const uptime = await this.getUptime();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
@ -279,7 +313,8 @@ export class InfoService {
|
|||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
slackCommunityUsers
|
slackCommunityUsers,
|
||||||
|
uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
@ -290,19 +325,54 @@ export class InfoService {
|
|||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<{
|
||||||
|
[offer in SubscriptionOffer]: Subscription;
|
||||||
|
}> {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prismaService.property.findUnique({
|
return (
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||||
});
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (stripeConfig) {
|
private async getUptime(): Promise<number> {
|
||||||
return [JSON.parse(stripeConfig.value)];
|
{
|
||||||
|
try {
|
||||||
|
const monitorId = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
|
)) as string;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
const { data } = await got(
|
||||||
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
subDays(new Date(), 90),
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
|
return data.attributes.availability / 100;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService - Better Stack');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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/configuration.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/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 {}
|
60
apps/api/src/app/logo/logo.service.ts
Normal file
60
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import got from 'got';
|
||||||
|
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 abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
return got(
|
||||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).buffer();
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -64,4 +65,8 @@ export class CreateOrderDto {
|
|||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,14 @@ export interface Activities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityError {
|
||||||
|
code: 'IS_DUPLICATE';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -37,12 +37,29 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
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()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteOrders(): Promise<number> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.orderService.deleteOrders({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@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> {
|
||||||
@ -69,10 +86,12 @@ export class OrderController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('skip') skip?: number,
|
||||||
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -81,34 +100,19 @@ export class OrderController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
activities = nullifyValuesInObjects(activities, [
|
|
||||||
'fee',
|
|
||||||
'feeInBaseCurrency',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +129,7 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
@ -146,6 +150,20 @@ export class OrderController {
|
|||||||
User: { connect: { id: this.request.user.id } },
|
User: { connect: { id: this.request.user.id } },
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.dataSource && !order.isDraft) {
|
||||||
|
// Gather symbol data in the background, if data source is set
|
||||||
|
// (not MANUAL) and not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: order.date,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@ -2,14 +2,15 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
|
|||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [AccountService, OrderService]
|
providers: [AccountBalanceService, AccountService, OrderService]
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
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';
|
||||||
@ -73,35 +74,42 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const defaultAccount = (
|
let Account;
|
||||||
await this.accountService.getAccounts(data.userId)
|
|
||||||
).find((account) => {
|
|
||||||
return account.isDefault === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tags = data.tags ?? [];
|
if (data.accountId) {
|
||||||
|
Account = {
|
||||||
let Account = {
|
connect: {
|
||||||
connect: {
|
id_userId: {
|
||||||
id_userId: {
|
userId: data.userId,
|
||||||
userId: data.userId,
|
id: data.accountId
|
||||||
id: data.accountId ?? defaultAccount?.id
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
const accountId = data.accountId;
|
||||||
|
let currency = data.currency;
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
|
const userId = data.userId;
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
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.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
@ -113,31 +121,23 @@ export class OrderService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol: id
|
symbol: id
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
this.dataGatheringService.addJobToQueue({
|
||||||
{
|
data: {
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
},
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
|
||||||
);
|
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
if (!isDraft) {
|
|
||||||
// Gather symbol data of order in the background, if not draft
|
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
date: <Date>data.date,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
},
|
||||||
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
})
|
||||||
}
|
}
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
@ -152,11 +152,20 @@ export class OrderService {
|
|||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
|
delete data.updateAccountBalance;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
return this.prismaService.order.create({
|
const isDraft =
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
@ -168,6 +177,27 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateAccountBalance === true) {
|
||||||
|
let amount = new Big(data.unitPrice)
|
||||||
|
.mul(data.quantity)
|
||||||
|
.plus(data.fee)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
if (data.type === 'BUY') {
|
||||||
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
userId,
|
||||||
|
date: data.date as Date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
@ -177,16 +207,31 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (
|
||||||
|
order.type === 'FEE' ||
|
||||||
|
order.type === 'INTEREST' ||
|
||||||
|
order.type === 'ITEM' ||
|
||||||
|
order.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||||
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
skip,
|
||||||
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
@ -194,6 +239,8 @@ export class OrderService {
|
|||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -272,6 +319,8 @@ export class OrderService {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
await this.orders({
|
await this.orders({
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -288,7 +337,11 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
@ -336,7 +389,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
@ -362,6 +420,12 @@ export class OrderService {
|
|||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
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,
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
|
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePlatformDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
url: string;
|
||||||
|
}
|
115
apps/api/src/app/platform/platform.controller.ts
Normal file
115
apps/api/src/app/platform/platform.controller.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Platform } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { CreatePlatformDto } from './create-platform.dto';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
import { UpdatePlatformDto } from './update-platform.dto';
|
||||||
|
|
||||||
|
@Controller('platform')
|
||||||
|
export class PlatformController {
|
||||||
|
public constructor(
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getPlatforms() {
|
||||||
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createPlatform(
|
||||||
|
@Body() data: CreatePlatformDto
|
||||||
|
): Promise<Platform> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platformService.createPlatform(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updatePlatform(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: UpdatePlatformDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalPlatform) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platformService.updatePlatform({
|
||||||
|
data: {
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalPlatform) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platformService.deletePlatform({ id });
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/platform/platform.module.ts
Normal file
13
apps/api/src/app/platform/platform.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PlatformController } from './platform.controller';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformController],
|
||||||
|
exports: [PlatformService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [PlatformService]
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
83
apps/api/src/app/platform/platform.service.ts
Normal file
83
apps/api/src/app/platform/platform.service.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlatform(
|
||||||
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.findUnique({
|
||||||
|
where: platformWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlatforms({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
cursor?: Prisma.PlatformWhereUniqueInput;
|
||||||
|
orderBy?: Prisma.PlatformOrderByWithRelationInput;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where?: Prisma.PlatformWhereInput;
|
||||||
|
} = {}) {
|
||||||
|
return this.prismaService.platform.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlatformsWithAccountCount() {
|
||||||
|
const platformsWithAccountCount =
|
||||||
|
await this.prismaService.platform.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Account: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return platformsWithAccountCount.map(({ _count, id, name, url }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
accountCount: _count.Account
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePlatform({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.PlatformUpdateInput;
|
||||||
|
where: Prisma.PlatformWhereUniqueInput;
|
||||||
|
}): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePlatformDto {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
url: string;
|
||||||
|
}
|
@ -2,6 +2,7 @@ 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 { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-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) {
|
||||||
@ -21,6 +22,17 @@ 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':
|
case 'NOVN.SW':
|
||||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
return { marketPrice: 87.8 };
|
return { marketPrice: 87.8 };
|
||||||
@ -37,8 +49,9 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||||
const result: GetValueObject[] = [];
|
const values: GetValueObject[] = [];
|
||||||
|
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -46,7 +59,7 @@ export const CurrentRateServiceMock = {
|
|||||||
date = addDays(date, 1)
|
date = addDays(date, 1)
|
||||||
) {
|
) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -59,7 +72,7 @@ export const CurrentRateServiceMock = {
|
|||||||
} else {
|
} else {
|
||||||
for (const date of dateQuery.in) {
|
for (const date of dateQuery.in) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -70,6 +83,7 @@ export const CurrentRateServiceMock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve(result);
|
|
||||||
|
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.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';
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
MarketDataService: jest.fn().mockImplementation(() => {
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
createdAt: date,
|
createdAt: date,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: DataSource.YAHOO,
|
||||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
marketPrice: 1847.839966
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getRange: ({
|
getRange: ({
|
||||||
@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -53,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
initialize: () => Promise.resolve(),
|
||||||
|
toCurrency: (value: number) => {
|
||||||
|
return 1 * value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
||||||
return {
|
return {
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
PropertyService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
initialize: () => Promise.resolve(),
|
getByKey: (key: string) => Promise.resolve({})
|
||||||
toCurrency: (value: number) => {
|
|
||||||
return 1 * value;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -71,9 +88,19 @@ describe('CurrentRateService', () => {
|
|||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
let propertyService: PropertyService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
propertyService = new PropertyService(null);
|
||||||
|
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
propertyService,
|
||||||
|
null
|
||||||
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -102,17 +129,16 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<GetValueObject[]>([
|
).toMatchObject<GetValuesObject>({
|
||||||
{
|
dataProviderInfos: [],
|
||||||
date: undefined,
|
errors: [],
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
values: [
|
||||||
symbol: 'AMZN'
|
{
|
||||||
},
|
date: undefined,
|
||||||
{
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
date: undefined,
|
symbol: 'AMZN'
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
}
|
||||||
symbol: 'AMZN'
|
]
|
||||||
}
|
});
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -22,34 +24,52 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<GetValueObject[]>[] = [];
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
|
const quoteErrors: ResponseError['errors'] = [];
|
||||||
|
const today = resetHours(new Date());
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes({ items: dataGatheringItems })
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
if (
|
||||||
date: today,
|
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||||
marketPriceInBaseCurrency:
|
) {
|
||||||
this.exchangeRateDataService.toCurrency(
|
dataProviderInfos.push(
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]
|
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||||
?.marketPrice ?? 0,
|
);
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
}
|
||||||
userCurrency
|
|
||||||
),
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
symbol: dataGatheringItem.symbol
|
result.push({
|
||||||
});
|
date: today,
|
||||||
|
marketPriceInBaseCurrency:
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
|
?.marketPrice,
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
quoteErrors.push({
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -81,7 +101,60 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return flatten(await Promise.all(promises));
|
const values = flatten(await Promise.all(promises));
|
||||||
|
|
||||||
|
const response: GetValuesObject = {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
}),
|
||||||
|
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEmpty(quoteErrors)) {
|
||||||
|
for (const { symbol } of quoteErrors) {
|
||||||
|
try {
|
||||||
|
// If missing quote, fallback to the latest available historical market price
|
||||||
|
let value: GetValueObject = response.values.find((currentValue) => {
|
||||||
|
return currentValue.symbol === symbol && isToday(currentValue.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
value = {
|
||||||
|
symbol,
|
||||||
|
date: today,
|
||||||
|
marketPriceInBaseCurrency: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
response.values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [latestValue] = response.values
|
||||||
|
.filter((currentValue) => {
|
||||||
|
return (
|
||||||
|
currentValue.symbol === symbol &&
|
||||||
|
currentValue.marketPriceInBaseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.date < b.date) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.date > b.date) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
value.marketPriceInBaseCurrency =
|
||||||
|
latestValue.marketPriceInBaseCurrency;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private containsToday(dates: Date[]): boolean {
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { GetValueObject } from './get-value-object.interface';
|
||||||
|
|
||||||
|
export interface GetValuesObject {
|
||||||
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
errors: ResponseError['errors'];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
@ -9,6 +9,7 @@ export interface PortfolioOrder {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
type: TypeOfOrder;
|
type: TypeOfOrder;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -7,6 +8,9 @@ import { Tag } from '@prisma/client';
|
|||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
|
dataProviderInfo: DataProviderInfo;
|
||||||
|
dividendInBaseCurrency: number;
|
||||||
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, Tag } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
|
|||||||
investment: Big;
|
investment: Big;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
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'),
|
||||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
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'),
|
||||||
|
@ -0,0 +1,146 @@
|
|||||||
|
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: '2015-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
@ -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: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
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') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,7 +1,12 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
TimelinePosition
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -19,9 +24,10 @@ import {
|
|||||||
isSameYear,
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
set
|
set,
|
||||||
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
@ -44,6 +50,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
private currency: string;
|
private currency: string;
|
||||||
private currentRateService: CurrentRateService;
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
private orders: PortfolioOrder[];
|
private orders: PortfolioOrder[];
|
||||||
private transactionPoints: TransactionPoint[];
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
@ -107,6 +114,7 @@ export class PortfolioCalculator {
|
|||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -118,6 +126,7 @@ export class PortfolioCalculator {
|
|||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
quantity: order.quantity.mul(factor),
|
quantity: order.quantity.mul(factor),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: 1
|
transactionCount: 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -176,10 +185,10 @@ export class PortfolioCalculator {
|
|||||||
return isBefore(parseDate(transactionPoint.date), end);
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
const currencies: { [symbol: string]: string } = {};
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
let day = start;
|
let day = start;
|
||||||
|
|
||||||
@ -201,14 +210,17 @@ export class PortfolioCalculator {
|
|||||||
symbols[item.symbol] = true;
|
symbols[item.symbol] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
currencies,
|
await this.currentRateService.getValues({
|
||||||
dataGatheringItems,
|
currencies,
|
||||||
dateQuery: {
|
dataGatheringItems,
|
||||||
in: dates
|
dateQuery: {
|
||||||
},
|
in: dates
|
||||||
userCurrency: this.currency
|
},
|
||||||
});
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
@ -226,19 +238,31 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const netPerformanceValuesBySymbol: {
|
const valuesByDate: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[date: string]: {
|
||||||
|
maxTotalInvestmentValue: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const investmentValuesBySymbol: {
|
const valuesBySymbol: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
investmentValues: { [date: string]: Big };
|
||||||
|
maxInvestmentValues: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
|
||||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
|
||||||
|
|
||||||
for (const symbol of Object.keys(symbols)) {
|
for (const symbol of Object.keys(symbols)) {
|
||||||
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
|
const {
|
||||||
|
currentValues,
|
||||||
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
end,
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
start,
|
start,
|
||||||
@ -247,50 +271,67 @@ export class PortfolioCalculator {
|
|||||||
isChartMode: true
|
isChartMode: true
|
||||||
});
|
});
|
||||||
|
|
||||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
valuesBySymbol[symbol] = {
|
||||||
investmentValuesBySymbol[symbol] = investmentValues;
|
currentValues,
|
||||||
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const currentDate of dates) {
|
for (const currentDate of dates) {
|
||||||
const dateString = format(currentDate, DATE_FORMAT);
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
totalNetPerformanceValues[dateString] =
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
|
||||||
|
|
||||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
const currentValue =
|
||||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||||
dateString
|
const investmentValue =
|
||||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||||
}
|
const maxInvestmentValue =
|
||||||
|
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
const netPerformanceValue =
|
||||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
totalInvestmentValues[dateString] =
|
valuesByDate[dateString] = {
|
||||||
totalInvestmentValues[dateString] ?? new Big(0);
|
totalCurrentValue: (
|
||||||
|
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
).add(currentValue),
|
||||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
totalInvestmentValue: (
|
||||||
dateString
|
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
).add(investmentValue),
|
||||||
}
|
maxTotalInvestmentValue: (
|
||||||
|
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||||
|
).add(maxInvestmentValue),
|
||||||
|
totalNetPerformanceValue: (
|
||||||
|
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||||
|
).add(netPerformanceValue)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
const {
|
||||||
|
maxTotalInvestmentValue,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalNetPerformanceValue
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: totalNetPerformanceValues[date]
|
: totalNetPerformanceValue
|
||||||
.div(totalInvestmentValues[date])
|
.div(maxTotalInvestmentValue)
|
||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
value: totalInvestmentValues[date]
|
value: totalCurrentValue.toNumber()
|
||||||
.plus(totalNetPerformanceValues[date])
|
|
||||||
.toNumber()
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -322,7 +363,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
const dates = [];
|
let dates = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
@ -351,7 +392,30 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
dates.push(resetHours(end));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
// Add dates of last week for fallback
|
||||||
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
).map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
});
|
||||||
|
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -360,6 +424,8 @@ export class PortfolioCalculator {
|
|||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
} = {};
|
} = {};
|
||||||
@ -414,6 +480,7 @@ export class PortfolioCalculator {
|
|||||||
: item.investment.div(item.quantity),
|
: item.investment.div(item.quantity),
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
|
fee: item.fee,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
grossPerformancePercentage: !hasErrors
|
grossPerformancePercentage: !hasErrors
|
||||||
@ -427,10 +494,17 @@ export class PortfolioCalculator {
|
|||||||
: null,
|
: null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (
|
||||||
|
(hasErrors ||
|
||||||
|
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||||
|
return dataSource === item.dataSource && symbol === item.symbol;
|
||||||
|
})) &&
|
||||||
|
item.investment.gt(0)
|
||||||
|
) {
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -445,6 +519,10 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
if (this.transactionPoints.length === 0) {
|
if (this.transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -462,51 +540,95 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
public getInvestmentsByGroup(
|
||||||
|
groupBy: GroupBy
|
||||||
|
): { date: string; investment: Big }[] {
|
||||||
if (this.orders.length === 0) {
|
if (this.orders.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments: { date: string; investment: Big }[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, order] of this.orders.entries()) {
|
for (const [index, order] of this.orders.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(order.date), currentDate) &&
|
isSameYear(parseDate(order.date), currentDate) &&
|
||||||
isSameYear(parseDate(order.date), currentDate)
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up investments
|
// Same group: Add up investments
|
||||||
|
investmentByGroup = investmentByGroup.plus(
|
||||||
investmentByMonth = investmentByMonth.plus(
|
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(order.date);
|
currentDate = parseDate(order.date);
|
||||||
investmentByMonth = order.quantity
|
investmentByGroup = order.quantity
|
||||||
.mul(order.unitPrice)
|
.mul(order.unitPrice)
|
||||||
.mul(this.getFactor(order.type));
|
.mul(this.getFactor(order.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === this.orders.length - 1) {
|
if (index === this.orders.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current group (latest order)
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return investments;
|
// Fill in the missing dates with investment = 0
|
||||||
|
const startDate = parseDate(first(this.orders).date);
|
||||||
|
const endDate = parseDate(last(this.orders).date);
|
||||||
|
|
||||||
|
const allDates: string[] = [];
|
||||||
|
currentDate = startDate;
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
allDates.push(
|
||||||
|
format(
|
||||||
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of allDates) {
|
||||||
|
const existingInvestment = investments.find((investment) => {
|
||||||
|
return investment.date === date;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingInvestment) {
|
||||||
|
investments.push({ date, investment: new Big(0) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
@ -662,7 +784,7 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||||
'PortfolioCalculator'
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -716,7 +838,7 @@ export class PortfolioCalculator {
|
|||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
if (dataGatheringItems.length > 0) {
|
if (dataGatheringItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
const { values } = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -725,6 +847,7 @@ export class PortfolioCalculator {
|
|||||||
},
|
},
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
marketSymbols = values;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
@ -858,12 +981,16 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
initialValue: new Big(0),
|
initialValue: new Big(0),
|
||||||
|
investmentValues: {},
|
||||||
|
maxInvestmentValues: {},
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
netPerformanceValues: {}
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -898,14 +1025,12 @@ export class PortfolioCalculator {
|
|||||||
let grossPerformanceFromSells = new Big(0);
|
let grossPerformanceFromSells = new Big(0);
|
||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
let maxTotalInvestment = new Big(0);
|
||||||
const netPerformanceValues: { [date: string]: Big } = {};
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||||
let totalUnits = new Big(0);
|
let totalUnits = new Big(0);
|
||||||
@ -1000,6 +1125,12 @@ export class PortfolioCalculator {
|
|||||||
for (let i = 0; i < orders.length; i += 1) {
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
const order = orders[i];
|
const order = orders[i];
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log();
|
||||||
|
console.log();
|
||||||
|
console.log(i + 1, order.type, order.itemType);
|
||||||
|
}
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
// Take the unit price of the order as the market price if there are no
|
// Take the unit price of the order as the market price if there are no
|
||||||
// orders of this symbol before the start date
|
// orders of this symbol before the start date
|
||||||
@ -1027,9 +1158,21 @@ export class PortfolioCalculator {
|
|||||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
const transactionInvestment =
|
||||||
.mul(order.unitPrice)
|
order.type === 'BUY'
|
||||||
.mul(this.getFactor(order.type));
|
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
|
: totalUnits.gt(0)
|
||||||
|
? totalInvestment
|
||||||
|
.div(totalUnits)
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(this.getFactor(order.type))
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log('totalInvestment', totalInvestment.toNumber());
|
||||||
|
console.log('order.quantity', order.quantity.toNumber());
|
||||||
|
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||||
|
}
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
@ -1078,70 +1221,44 @@ export class PortfolioCalculator {
|
|||||||
? new Big(0)
|
? new Big(0)
|
||||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
console.log(
|
||||||
.plus(grossPerformanceFromSells);
|
'totalInvestmentWithGrossPerformanceFromSell',
|
||||||
|
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||||
if (
|
);
|
||||||
i > indexOfStartOrder &&
|
console.log(
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
'grossPerformanceFromSells',
|
||||||
.plus(lastTransactionInvestment)
|
grossPerformanceFromSells.toNumber()
|
||||||
.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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestment)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
feesAtStartDate = fees;
|
feesAtStartDate = fees;
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChartMode && i > indexOfStartOrder) {
|
if (isChartMode && i > indexOfStartOrder) {
|
||||||
|
currentValues[order.date] = valueOfInvestment;
|
||||||
netPerformanceValues[order.date] = grossPerformance
|
netPerformanceValues[order.date] = grossPerformance
|
||||||
.minus(grossPerformanceAtStartDate)
|
.minus(grossPerformanceAtStartDate)
|
||||||
.minus(fees.minus(feesAtStartDate));
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
investmentValues[order.date] = totalInvestment;
|
investmentValues[order.date] = totalInvestment;
|
||||||
|
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log('totalInvestment', totalInvestment.toNumber());
|
||||||
|
console.log(
|
||||||
|
'totalGrossPerformance',
|
||||||
|
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i === indexOfEndOrder) {
|
if (i === indexOfEndOrder) {
|
||||||
@ -1149,12 +1266,6 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
grossPerformanceAtStartDate
|
grossPerformanceAtStartDate
|
||||||
);
|
);
|
||||||
@ -1218,6 +1329,7 @@ export class PortfolioCalculator {
|
|||||||
Average price: ${averagePriceAtStartDate.toFixed(
|
Average price: ${averagePriceAtStartDate.toFixed(
|
||||||
2
|
2
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||||
|
Total investment: ${totalInvestment.toFixed(2)}
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
2
|
2
|
||||||
@ -1230,14 +1342,16 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialValue,
|
currentValues,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
|
initialValue,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
netPerformanceValues,
|
netPerformanceValues,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
netPerformance: totalNetPerformance,
|
netPerformance: totalNetPerformance
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,17 +8,20 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
HEADER_KEY_IMPERSONATION
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioDividends,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@ -47,8 +50,6 @@ 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 apiService: ApiService,
|
||||||
@ -57,23 +58,26 @@ export class PortfolioController {
|
|||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
|
let hasDetails = true;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
@ -86,6 +90,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
summary,
|
summary,
|
||||||
totalValueInBaseCurrency
|
totalValueInBaseCurrency
|
||||||
} = await this.portfolioService.getDetails({
|
} = await this.portfolioService.getDetails({
|
||||||
@ -127,14 +132,24 @@ 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.valueInBaseCurrency / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||||
accounts[name].current = current / totalValue;
|
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
accounts[name].original = original / totalInvestment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
|
||||||
|
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasDetails === false ||
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
portfolioSummary = nullifyValuesInObject(summary, [
|
portfolioSummary = nullifyValuesInObject(summary, [
|
||||||
'cash',
|
'cash',
|
||||||
'committedFunds',
|
'committedFunds',
|
||||||
@ -145,26 +160,33 @@ export class PortfolioController {
|
|||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
|
'fireWealth',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
|
'totalInvestment',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
holdings[symbol] = {
|
holdings[symbol] = {
|
||||||
...portfolioPosition,
|
...portfolioPosition,
|
||||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
assetClass:
|
||||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||||
|
? portfolioPosition.assetClass
|
||||||
|
: undefined,
|
||||||
|
assetSubClass:
|
||||||
|
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||||
|
? portfolioPosition.assetSubClass
|
||||||
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
marketsAdvanced: hasDetails
|
||||||
|
? portfolioPosition.marketsAdvanced
|
||||||
|
: undefined,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -175,42 +197,85 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
totalValueInBaseCurrency,
|
totalValueInBaseCurrency,
|
||||||
summary: hasDetails ? portfolioSummary : undefined
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getDividends(
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) 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(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@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: InvestmentItem[];
|
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||||
|
dateRange,
|
||||||
if (groupBy === 'month') {
|
filters,
|
||||||
investments = await this.portfolioService.getInvestments({
|
groupBy,
|
||||||
dateRange,
|
impersonationId,
|
||||||
impersonationId,
|
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||||
groupBy: 'month'
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
investments = await this.portfolioService.getInvestments({
|
|
||||||
dateRange,
|
|
||||||
impersonationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -225,9 +290,28 @@ export class PortfolioController {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment / maxInvestment
|
investment: item.investment / maxInvestment
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { investments };
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
investments = investments.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { investments, streaks };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@ -235,12 +319,23 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('range') dateRange: DateRange = 'max'
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
const performanceInformation = await this.portfolioService.getPerformance({
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
dateRange,
|
dateRange,
|
||||||
impersonationId
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -256,7 +351,7 @@ export class PortfolioController {
|
|||||||
totalInvestment: new Big(totalInvestment)
|
totalInvestment: new Big(totalInvestment)
|
||||||
.div(performanceInformation.performance.totalInvestment)
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
.toNumber(),
|
.toNumber(),
|
||||||
value: new Big(value)
|
valueInPercentage: new Big(value)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(performanceInformation.performance.currentValue)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
@ -274,39 +369,48 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
|
(item) => {
|
||||||
|
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return performanceInformation;
|
return performanceInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@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(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('range') dateRange: DateRange = 'max'
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
impersonationId,
|
filterByAccounts,
|
||||||
dateRange
|
filterByAssetClasses,
|
||||||
);
|
filterBySearchQuery,
|
||||||
|
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> {
|
||||||
@ -331,7 +435,7 @@ export class PortfolioController {
|
|||||||
dateRange: 'max',
|
dateRange: 'max',
|
||||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
impersonationId: access.userId,
|
impersonationId: access.userId,
|
||||||
userId: access.userId
|
userId: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
@ -345,24 +449,26 @@ 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?.settings.baseCurrency ??
|
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
|
||||||
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)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.value / totalValue,
|
allocationInPercentage:
|
||||||
|
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
value: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,35 +476,22 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
const position = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,18 +504,21 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) 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.portfolioService.getReport(impersonationId);
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,15 +2,16 @@ 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 { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
|||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
import type { RedisStore } from './redis-store.interface';
|
||||||
|
|
||||||
|
export interface RedisCache extends Cache {
|
||||||
|
store: RedisStore;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { Store } from 'cache-manager';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
export interface RedisStore extends Store {
|
||||||
|
getClient: () => ReturnType<typeof createClient>;
|
||||||
|
isCacheableValue: (value: any) => boolean;
|
||||||
|
name: 'redis';
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
import type { RedisClientOptions } from 'redis';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
imports: [ConfigurationModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigurationService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => {
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
return <CacheManagerOptions>{
|
return <RedisClientOptions>{
|
||||||
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'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
|
@ -1,18 +1,32 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Cache } from 'cache-manager';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisCacheService {
|
export class RedisCacheService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {
|
||||||
|
const client = cache.store.getClient();
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
Logger.error(error, 'RedisCacheService');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<string> {
|
public async get(key: string): Promise<string> {
|
||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
}
|
}
|
||||||
@ -22,8 +36,10 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(
|
||||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
key,
|
||||||
});
|
value,
|
||||||
|
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getYesterday,
|
||||||
|
interpolate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Controller('sitemap.xml')
|
||||||
|
export class SitemapController {
|
||||||
|
public sitemapXml = '';
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
try {
|
||||||
|
this.sitemapXml = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
public async flushCache(@Res() response: Response): Promise<void> {
|
||||||
|
response.setHeader('content-type', 'application/xml');
|
||||||
|
response.send(
|
||||||
|
interpolate(this.sitemapXml, {
|
||||||
|
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SitemapController } from './sitemap.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SitemapController],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SitemapModule {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user